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)}
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
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)
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))
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
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
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))
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
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))
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)
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
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
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()
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)}
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:
-
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_pctfeature we engineered earlier is not decoration — it is the first filter. Ifspread_pct > 0.5%, the predictor's probability is academic; the executor will slip. -
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
pcrand per-strike OI slope are features, not afterthoughts. -
Volume confirms, OI positions. Rising volume with rising OI = new money committing (trend confirmation). Rising volume with falling OI = squaring (exhaustion). The model treats
volumeas 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
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)
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_bpsin 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/volumeat 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
- Using accuracy on imbalanced data.
- A single 0.50 threshold instead of a tuned band.
- Uncalibrated probabilities fed to the risk layer.
- Random oversampling that leaks the future.
- 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)