DEV Community

shakti tiwari
shakti tiwari

Posted on • Originally published at optiontradingwithai.in

Classification Models for Options Trading Signals (Probabilities, Calibration, Thresholds)

Classification models for trading signals

Answer-first: A classification model for options signals outputs a probability that the next-window move is in your favored direction. The model is only half the system — you must calibrate that probability, pick a decision threshold, and wrap it in Greeks-based risk limits. This guide covers the parts beginners skip: calibration, imbalance, and threshold selection, with runnable code.

Educational only. Not investment advice. Options can lose their full value. Consult a licensed advisor.

Classification vs regression for signals

  • Regression predicts the magnitude of the move (noisy, hard to act on).
  • Classification predicts the direction (up vs down) as a probability — easier to threshold and safer to cap.

For retail options, classification wins because you do not need the exact price; you need "is the edge real enough to risk theta?" A probability answers that.

From probability to action

The raw output p is not a trade. The decision rule is:

def decide(p, greeks, max_capital, risk=0.01):
    if not (0.58 <= p <= 0.80):   # band, not a single cut
        return {}
    if greeks["dte"] <= 1:
        return {}
    if abs(greeks["vega"]) > 8.0:
        return {}
    size = (max_capital * risk) / max(greeks["theta"], 1e-9)
    return {"action": "paper_entry", "size": round(size, 2)}
Enter fullscreen mode Exit fullscreen mode

A band (0.58-0.80), not a hard 0.50 cut, rejects both weak and overconfident predictions.

Calibration: is your probability honest?

A model can have high AUC yet be poorly calibrated — saying 0.70 when it is right only 55% of the time. Calibrate:

# Mac / Linux / Termux
python3 calibrate.py
# Windows CMD
py calibrate.py
Enter fullscreen mode Exit fullscreen mode
from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import TimeSeriesSplit

base = HistGradientBoostingClassifier(max_depth=4, learning_rate=0.05, max_iter=300)
# prefer="cv" uses time-series folds in practice; here we show the API
cal = CalibratedClassifierCV(base, method="isotonic", cv=5)
cal.fit(X, y)
Enter fullscreen mode Exit fullscreen mode

Always check a reliability curve: predicted probability on x, observed frequency on y. A diagonal line means honest. A bowed line means recalibrate before you trust the band.

Imbalanced data

Options signals are imbalanced: after fees, only ~45-55% of "reasonable" setups win. If your label is rare (e.g. "big move > 2%"), use:

  • class_weight="balanced" in the model, or
  • stratified time-series sampling so the minority class is not drowned.

Never oversample randomly on time-series — you leak the future. Use SMOTE only inside each training fold, never across the split.

Metrics that matter (not just accuracy)

Accuracy lies on imbalanced data. Use:

Metric Why
AUC Ranks predictions; threshold-independent
Precision of entries taken, how many won
Recall of real opportunities, how many caught
Brier score calibration quality (lower better)
PR-AUC better than ROC-AUC when positive class is rare
from sklearn.metrics import brier_score_loss, average_precision_score
print("Brier:", round(brier_score_loss(y_te, p_te), 4))
print("PR-AUC:", round(average_precision_score(y_te, p_te), 3))
Enter fullscreen mode Exit fullscreen mode

Threshold tuning with cost

The best threshold is not 0.50 — it is where expected value turns positive after fees and theta:

def best_threshold(p, y, fees_bps=2.0):
    best, best_ev = 0.5, -1e9
    for t in [i/100 for i in range(50, 85)]:
        pred = (p >= t).astype(int)
        ev = ((pred == y).astype(float) - fees_bps/10000.0).sum()
        if ev > best_ev:
            best_ev, best = ev, t
    return best
Enter fullscreen mode Exit fullscreen mode

This sweeps thresholds 0.50-0.84 and picks the one with the highest net expected value — exactly the band your rules engine should use.

Walk-forward, not one split

from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=10)
for tr, te in tscv.split(X):
    model.fit(X.iloc[tr], y.iloc[tr])
    # score on te, never train
Enter fullscreen mode Exit fullscreen mode

A static split hides decay. Walk-forward (slide the window) shows whether the signal survives live.

Explainability for a signal

Use permutation importance, not SHAP if you are on a phone:

from sklearn.inspection import permutation_importance
pi = permutation_importance(model, X_te, y_te, n_repeats=10)
for f, i in sorted(zip(FEATURES, pi.importances_mean), key=lambda x:-x[1])[:5]:
    print(f, round(i, 4))
Enter fullscreen mode Exit fullscreen mode

If oi/volume rank top, you leaked. Fix the label, retrain.

Multiclass for strategy selection

Binary up/down is not the only classification. Pick the structure too:

# Mac / Linux / Termux
python3 strategy_cls.py
# Windows CMD
py strategy_cls.py
Enter fullscreen mode Exit fullscreen mode
from sklearn.ensemble import HistGradientBoostingClassifier

# labels: 0=no trade, 1=long call, 2=long put, 3=vertical, 4=iron condor
mc = HistGradientBoostingClassifier(max_depth=4, learning_rate=0.05, max_iter=300)
mc.fit(X, y_strategy)
print("strategy probs:", mc.predict_proba(X_new))
Enter fullscreen mode Exit fullscreen mode

Now the model selects both direction and structure from one feature set — the risk layer still enforces hard limits per class.

Ensemble of classifiers

Combine three classifiers and average probabilities for stability:

from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression

models = [HistGradientBoostingClassifier(max_depth=4, max_iter=300),
          RandomForestClassifier(n_estimators=120, max_depth=6),
          LogisticRegression(max_iter=500)]
probs = np.mean([m.fit(X_tr, y_tr).predict_proba(X_te)[:,1] for m in models], axis=0)
Enter fullscreen mode Exit fullscreen mode

Averaging reduces single-model variance; the band then acts on the smoothed probability.

Live deployment loop

A paper-trader ties it together:

# Mac / Linux / Termux
python3 live_loop.py
# Windows CMD
py live_loop.py
Enter fullscreen mode Exit fullscreen mode
import time
def live_loop(model, get_chain, sleep_s=60):
    while True:
        chain = get_chain()                 # fetch current options chain
        feats = build_features(chain, pcr=get_pcr())
        p = model.predict_proba(feats)[:, 1]
        for row in feats.itertuples():
            g = row._asdict()
            action = decide(p[row.Index], g, max_capital=10000)
            if action:
                print("paper_entry", action)
        time.sleep(sleep_s)                  # respect broker rate limits
Enter fullscreen mode Exit fullscreen mode

This is the skeleton real bots run — fetch, featurize, predict, decide, log. Nothing more.

Cost-based threshold recap

The threshold is where net expected value (after fees and theta) turns positive, not 0.50. Sweep 0.50-0.84, pick the max-EV cut, and feed that band to the rules engine. Calibrate first or the sweep is on dishonest probabilities.

The Full Production Pipeline (Data Engine -> Predictor -> Filter)

A published article often shows only the model and backtest. The production system that actually runs has four stages between raw market data and a trade:

1. DATA ENGINE     fetch chain + IV + PCR + vol-index every N seconds
2. FEATURE ENGINE  build_features() -> clean, dedup, label
3. PREDICTOR       gradient-boosting model -> prob_up per strike
4. FILTER          Greeks + regime + prob-band rules -> allow/block
5. EXECUTOR        paper or live entry sized by position_size()
Enter fullscreen mode Exit fullscreen mode

1. Data Engine

Connects to the broker/exchange feed (NSE, Eurex, OSE, Euronext, LSE, TMX, ASX, HKEX, SGX, KRX, etc.) and snapshots the full options chain on a timer. It must:

  • Dedupe cross-venue snapshots (Euronext shares one book).
  • Cap snapshot latency under the decision window.
  • Survive a feed gap without feeding stale mid quotes to the model.

2. Feature Engine

Runs build_features() on the raw snapshot: mid, spread_pct, moneyness, iv_skew, pcr, theta_per_delta. This is where bad data dies — a strike with no OI or a synthetic CFD quote is dropped before the model sees it.

3. Predictor

The trained HistGradientBoostingClassifier outputs prob_up per strike. It is stateless at inference time — load once, predict many.

4. Filter (the part most beginners skip)

The predictor is NOT the trade. The filter is a hard rules layer:

def filter(prob_up, greeks, vol_z, max_capital):
    if not (0.58 <= prob_up <= 0.80):
        return {}
    if greeks["dte"] <= 1:
        return {}
    if abs(greeks["vega"]) > 8.0:
        return {}
    if vol_z > 2.0:                  # vol spike -> shrink
        max_capital *= 0.5
    size = (max_capital * 0.01) / max(greeks["theta"], 1e-9)
    return {"action": "paper_entry", "size": round(size, 2)}
Enter fullscreen mode Exit fullscreen mode

The filter is what makes the system survive a regime the model never saw in training.

5. Executor

Turns the allowed signal into a sized order. Paper first (log every fill), then live only after the broker review. Never skip stage 4.

This five-stage split is why a 1500-word model section is not the whole product — the data engine and the filter carry as much weight as the predictor.

Market Microstructure & Liquidity (why it matters for the model)

A signal is only as good as the liquidity it trades into. Three microstructure facts the model must respect:

  1. Bid-ask spread eats thin edges. An ATM option with a 0.3% spread needs the signal to clear more than 0.3% just to break even. The spread_pct feature we engineered earlier is not decoration — it is the first filter. If spread_pct > 0.5%, the predictor's probability is academic; the executor will slip.
  2. Open Interest build-up defines support/resistance. When OI piles at a strike, that strike acts as a magnet or wall at expiry. A model that ignores OI concentration misprices the pinning effect. This is why pcr and per-strike OI slope are features, not afterthoughts.
  3. Volume confirms, OI positions. Rising volume with rising OI = new money committing (trend confirmation). Rising volume with falling OI = squaring (exhaustion). The model treats volume as a confirmation flag, never as a standalone predictor, because volume without OI context is noise.

Practical checklist before trusting any entry: spread tight, OI slope sensible vs the signal direction, and volume not in exhaustion pattern.

Volatility Regime Detection (real code)

Markets are not stationary. A model trained in calm IV behaves badly in a vol spike. Detect regime from the vol index and switch logic:

# Mac / Linux / Termux
python3 regime.py
# Windows CMD
py regime.py
Enter fullscreen mode Exit fullscreen mode
def regime_state(vix, vix_ma20):
    z = (vix - vix_ma20) / (vix_ma20 + 1e-9)
    if z > 2.0:
        return "CRASH", 0.5      # halve size
    if z > 1.0:
        return "STRESS", 0.75    # shrink size
    if z < -1.0:
        return "CALM", 1.0       # full size
    return "NORMAL", 1.0

def size_with_regime(base_capital, z, max_capital):
    _, mult = regime_state(vix=z, vix_ma20=1.0)
    return (max_capital * 0.01 * mult) / max(base_capital, 1e-9)
Enter fullscreen mode Exit fullscreen mode

The CRASH state cuts size to 50% — this single rule is what keeps a strategy alive across the 2020-style gaps that destroy naive bots. The model's probability is unchanged; only the executor's capital adapts.

Execution & Broker Reality

Backtest assumes fills at mid. Live fills at ask (buy) / bid (sell), plus brokerage and STT. Three realities:

  • Brokerage + taxes: per-lot flat fee plus exchange charges. A round-trip on a cheap option can cost 0.5-1% — model this as fees_bps in backtest, not zero.
  • Slippage: in fast markets the quoted mid moves between signal and fill. Cap position size so slippage stays under the edge.
  • Margin: short options need margin blocks; long options need premium. The position_size() function already sizes from premium risk, so a long option's max loss is known upfront.

Never let a backtest show profit that a live account cannot realize after fees. If the net-after-fees AUC-era return is negative, the signal is not an edge — it is a fee generator for the broker.

A Realistic Weekly Routine

Consistency beats bursts. A workable week for this system:

  • Monday: pull last week's chain CSV, retrain if drift alert fired, review regime state.
  • Tuesday–Thursday: run the paper loop during market hours; log every entry/exit with the model's probability and the filter's decision.
  • Friday: if expiry week, tighten DTE limits; review realized vs predicted.
  • Weekend: read one regulatory update; check if broker margin rules changed.

This is not a get-rich loop. It is a measurement loop. After 8-12 weeks of honest paper logs you will know your true edge — and that number, not a backtest chart, is what you size against.

FAQ (extra)

Q6. Multiclass or binary?
Binary for signal, multiclass for structure selection. Start binary; add multiclass once the band is stable.

Q7. Ensemble worth the cost?
Three models cut variance ~20%; worth it when a single bad fill ends the week, negligible extra on a phone.

Q8. How fast should the loop run?
Once per minute is plenty for options; faster only burns the broker rate limit and adds no edge.

Worked numeric example

Suppose your classifier outputs p=0.66 on a KOSPI 200 vertical. You run best_threshold over 0.50-0.84 and find max net-EV at 0.60 (fees + theta included). You feed the band 0.58-0.80 to the engine. Reliability curve is near-diagonal, so the probability is honest. Over 15 sessions, entries taken at p>=0.60 win 59% of the time — the calibration held. If the curve had bowed (said 0.66 but won 52%), you would have recalibrated with CalibratedClassifierCV before trusting it.

Glossary

  • Classification: predicts a category (here: direction) as probability.
  • Threshold: cut point turning probability into action.
  • Calibration: predicted prob matches observed frequency.
  • Imbalance: one class far rarer than the other.
  • PR-AUC: precision-recall area; better than ROC when rare.
  • Brier score: calibration error; lower is better.
  • Band: accepted probability range, not a single cut. ## Deployment checklist (classification signal)

Before any paper trade:

  • [ ] TimeSeriesSplit AUC printed, not random-split.
  • [ ] Reliability curve near-diagonal (calibrated).
  • [ ] Threshold chosen by max net-EV sweep, not 0.50.
  • [ ] Band 0.58-0.80 fed to the rules engine.
  • [ ] Class imbalance handled (class_weight / PR-AUC).
  • [ ] Permutation importance sane (no oi/volume at top).
  • [ ] Walk-forward mean AUC stable across windows.
  • [ ] Risk layer hard limits active (dte, vega, theta).

Miss any one and you are trading an unverified probability — the fastest way to let theta bleed the account.

Common mistakes

  1. Using accuracy on imbalanced data.
  2. A single 0.50 threshold instead of a tuned band.
  3. Uncalibrated probabilities fed to the risk layer.
  4. Random oversampling that leaks the future.
  5. No walk-forward — static AUC hides decay.

When classification is the wrong tool

  • You need the exact move size → regression.
  • You need ranking of many strikes → learning-to-rank.
  • The signal is regime-dependent → train per-regime models (see gradient-boosting guide).

FAQ

Q1. Why a probability band and not a cut at 0.50?
A band rejects both weak (0.51) and overconfident (>0.80, often leakage) predictions; only the honest middle acts.

Q2. How do I know my probability is honest?
Plot a reliability curve; if it bows, run CalibratedClassifierCV before trusting the band.

Q3. My data is 95% "no signal" — what then?
Use class_weight="balanced" and PR-AUC instead of accuracy; never random-oversample across time.

Q4. Is AUC enough to trade?
No. AUC is threshold-free. You need calibration + a cost-based threshold + risk limits on top.

Q5. Can this run on a phone?
Yes. HistGradientBoosting + permutation importance run on Termux or a Raspberry Pi; no GPU needed.

Footer

Shakti Tiwari — Options Trader, XGBoost Expert.
Books: Option Trading with AI (B0H9ZNTBPK) · The AI Opportunity (B0HBBFKDQF)
Site: optiontradingwithai.in · Free help: shaktitiwari715@gmail.com
Dev.to: @shaktitiwari · X: @shaktitiwari

Top comments (0)