Answer-first: You can build an AI-assisted options-trading bot for S&P 500 (SPX) options by combining three layers — (1) a feature pipeline that turns options-chain and volatility data into model inputs, (2) a gradient-boosting classifier that scores short-term directional probability, and (3) a backtest harness that enforces Greeks-based risk limits. The model never "places money"; it emits a probability the human overlays with a rules engine. Below is a runnable Python scaffold you can extend.
Most "AI trading bot" tutorials stop at a stock-price LSTM and call it a day. Options are a different animal: a 1% move in the underlying can mean +40% or −100% on the option depending on delta, days-to-expiry, and implied volatility. This article is a code-first, no-hype blueprint for the S&P 500 options market specifically, written for retail quants who already know Python.
Educational use only. This is not investment advice. Trading options carries the risk of total loss. Consult the SEC/FINRA guidelines and a licensed advisor before risking capital.
Why S&P 500 options (SPX) are a good AI target
The S&P 500 index options market is the deepest, most liquid options venue in the world. That liquidity gives you:
- Tight bid/ask spreads → less slippage, cleaner labels for supervised learning.
- Continuous pricing → you can build intraday features, not just EOD.
- Rich derivatives data → IV surface, PCR, open interest, and term structure are all publicly discussed and partially free.
For a model, liquidity means your predicted edge isn't eaten by transaction costs — a prerequisite for any backtest to be believable.
Architecture of the bot (three layers)
┌─────────────────────────────┐
│ 1. Data / Feature Pipeline │ options chain, IV, PCR, OI, VIX
└──────────────┬──────────────┘
│ features
┌──────────────▼──────────────┐
│ 2. Model (gradient boosting) │ P(direction | features)
└──────────────┬──────────────┘
│ probability
┌──────────────▼──────────────┐
│ 3. Rules + Greeks Engine │ position sizing, stop, DTE limit
└──────────────┬──────────────┘
│ order intent (paper)
┌──────▼──────┐
│ Backtest │ pandas vectorized P&L with Greek limits
└─────────────┘
The model is a probability generator, not an execution agent. A separate rules layer decides whether to act. This separation is what keeps the system auditable and compliant with broker risk policies.
Layer 1 — Feature engineering (runnable)
We build a feature row per (underlying, expiry, timestamp). The label is "does the option's intrinsic+time value rise in the next N minutes?" — a simplified directional proxy.
# Mac Terminal / Linux / Termux
python3 features.py
# Windows CMD
py features.py
# features.py
import pandas as pd
import numpy as np
def build_features(chain: pd.DataFrame, vix: float, pcr: float) -> pd.DataFrame:
"""chain: one options chain snapshot with columns
['strike','bid','ask','iv','delta','gamma','theta','vega','oi','volume','spot','dte']"""
df = chain.copy()
df["mid"] = (df["bid"] + df["ask"]) / 2.0
df["spread_pct"] = (df["ask"] - df["bid"]) / df["mid"].clip(lower=1e-9)
df["moneyness"] = df["strike"] / df["spot"] - 1.0
# IV skew feature: how far this strike's IV is from ATM IV
atm_iv = df.loc[(df["moneyness"].abs()).idxmin(), "iv"]
df["iv_skew"] = df["iv"] - atm_iv
df["vix"] = vix
df["pcr"] = pcr
# theta/vega efficiency: decay cost per unit of directional exposure
df["theta_per_delta"] = df["theta"] / df["delta"].clip(lower=1e-9)
return df
if __name__ == "__main__":
# synthetic demo row so the snippet runs without a live feed
demo = pd.DataFrame([{
"strike": 5000, "bid": 12.0, "ask": 12.5, "iv": 0.18,
"delta": 0.52, "gamma": 0.003, "theta": -1.2, "vega": 4.1,
"oi": 90000, "volume": 5000, "spot": 4980, "dte": 7,
}])
feats = build_features(demo, vix=14.5, pcr=0.92)
print(feats[["mid","spread_pct","moneyness","iv_skew","theta_per_delta"]].to_string())
Run it:
mid 12.25
spread_pct 0.0408
moneyness 0.0040
iv_skew 0.0000
theta_per_delta -2.3077
These five features (plus VIX and PCR) are enough for a first model. Real systems add term-structure slope, gamma-flip proximity, and cross-expiry skew.
Layer 2 — The model (gradient boosting, not a neural net)
For tabular options data, gradient-boosted trees (XGBoost / LightGBM) usually beat deep nets on small-to-medium datasets and are far easier to audit. We frame it as binary classification: label = 1 if the option's mid price is higher in the next window.
# Mac / Linux / Termux
python3 train.py
# Windows CMD
py train.py
# train.py
import pandas as pd
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import TimeSeriesSplit, roc_auc_score
FEATURES = ["spread_pct","moneyness","iv_skew","vix","pcr","theta_per_delta",
"gamma","vega","dte","oi","volume"]
def train(X: pd.DataFrame, y: pd.Series):
tscv = TimeSeriesSplit(n_splits=5) # never shuffle time-series!
model = HistGradientBoostingClassifier(
max_depth=4, learning_rate=0.05, max_iter=300)
for tr, te in tscv.split(X):
model.fit(X.iloc[tr], y.iloc[tr])
pred = model.predict_proba(X.iloc[te])[:, 1]
print("fold AUC:", round(roc_auc_score(y.iloc[te], pred), 3))
model.fit(X, y)
return model
# In production, X/y are built from historical chain snapshots with
# forward-looking labels. We omit the data loader for brevity.
Why time-series split, not random? Random splitting leaks future information into training and inflates AUC to useless levels. A TimeSeriesSplit is the single most common mistake beginners make here.
The "best AI trader" systems in production almost always pair a simple,
auditable model with a strict risk layer — not a black-box net.
Layer 3 — Greeks-based rules engine
The model says "62% up." The rules engine decides if and how much. Hard limits prevent the classic blow-up: selling naked options, holding through expiry, ignoring vega risk.
# Mac / Linux / Termux
python3 risk.py
# Windows CMD
py risk.py
# risk.py
def decide(prob_up: float, greeks: dict, max_capital: float,
risk_per_trade: float = 0.01) -> dict:
"""Return an order intent or {} if rejected by a hard rule."""
# Hard rule 1: only act on confident, non-extreme probabilities
if not (0.58 <= prob_up <= 0.80):
return {}
# Hard rule 2: never hold into the last 1 DTE (assignment/gamma risk)
if greeks["dte"] <= 1:
return {}
# Hard rule 3: cap vega exposure (IV crush protection)
if abs(greeks["vega"]) > 8.0:
return {}
size = (max_capital * risk_per_trade) / max(greeks["theta"], 1e-6)
return {"action": "paper_entry", "size": round(size, 2),
"stop_theta": greeks["theta"] * 2.5}
These three rules alone remove the majority of catastrophic outcomes retail options traders hit. The model can be mediocre and the system still survivable; the reverse is never true.
Backtest harness (pandas vectorized P&L)
A backtest must include transaction cost and Greeks unrealized P&L, not just directional hits. Here's a minimal vectorized P&L using mid-to-mid moves and a theta accrual.
# Mac / Linux / Termux
python3 backtest.py
# Windows CMD
py backtest.py
# backtest.py
import pandas as pd
import numpy as np
def backtest(signals: pd.DataFrame, fees_bps: float = 2.0) -> float:
"""signals: columns ['prob_up','mid','delta','theta','dte','spot_ret']"""
s = signals.copy()
s["position"] = ((s["prob_up"] >= 0.60) & (s["dte"] > 1)).astype(int)
# P&L per contract: directional part via delta * spot move, minus theta decay
s["pnl"] = s["position"] * (s["delta"] * s["spot_ret"] * 100
- s["theta"] + s["prob_up"] - 0.5)
s["pnl"] -= (s["position"] * fees_bps / 10000.0) # round-trip-ish fee
return s["pnl"].sum()
# Demo: 200 rows of random-ish signal to show the harness runs
rng = np.random.default_rng(7)
demo = pd.DataFrame({
"prob_up": rng.uniform(0.4, 0.9, 200),
"mid": rng.uniform(10, 30, 200),
"delta": rng.uniform(0.2, 0.8, 200),
"theta": rng.uniform(-2, -0.2, 200),
"dte": rng.integers(2, 30, 200),
"spot_ret": rng.normal(0, 0.001, 200),
})
print("demo backtest P&L:", round(backtest(demo), 2))
A real backtest replaces the demo with historical chain snapshots and forward labels, and adds walk-forward evaluation so the AUC you trust is out-of-sample.
Volatility regime filter (VIX)
The same model behaves differently in low vs high volatility. A simple regime gate improves robustness:
- VIX < 15: favor low-theta, longer-DTE structures.
- VIX 15–25: baseline mode.
- VIX > 25: shrink size by half, widen the probability band, skip short-DTE.
This is a one-line if in the rules engine and historically cuts tail losses more than any feature tweak.
Common mistakes (don't ship these)
- Random train/test split on time-series → fake AUC.
- Ignoring bid/ask spread → profitable on paper, dead in live.
- Naked short options for "higher probability" → one tail event ends the account.
- Overfitting IV skew to a single regime → fails at VIX expansion.
- No position sizing → right 60% of the time but-sized-to-blow-up.
Weekly routine for a retail quant
- Mon: rebuild features from Friday's chain; retrain if AUC drifted > 3%.
- Tue–Thu: paper-trade the signal; log fills vs predicted probability.
- Fri: review false positives; tighten rules, not the model.
- Monthly: walk-forward re-evaluation on fresh data only.
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
Q1. Do I need a neural network for S&P 500 options?
No. Gradient-boosted trees on well-built features typically match or beat nets on tabular options data and are easier to audit for a retail account.
Q2. Is this legal under SEC/FINRA rules?
Building and paper-trading your own model is legal. Automating live orders triggers broker risk-review and may require registration depending on how you operate. Keep it paper-first and consult a compliance professional.
Q3. How much capital should I risk per trade?
A common retail rule is ≤1% of capital per trade, scaled by the Greeks (see decide() above). Never risk what you can't lose.
Q4. Can I run this from a phone or Raspberry Pi?
Yes. The pipeline is pure Python/pandas; a Termux or Pi setup handles feature builds and paper signals fine. Live broker API access is the only heavy part.
Q5. What's the biggest edge — the model or the risk layer?
The risk layer. A mediocre model with strict Greek limits survives; a great model with none does not.
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













