DEV Community

shakti tiwari
shakti tiwari

Posted on Edited on Originally published at optiontradingwithai.in

How to Build an AI Options Trading Bot for the S&P 500 (Real Python, Greeks, Backtest)

S&P 500 AI options trading bot architecture

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
        └─────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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())
Enter fullscreen mode Exit fullscreen mode

Run it:

mid           12.25
spread_pct     0.0408
moneyness      0.0040
iv_skew        0.0000
theta_per_delta -2.3077
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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))
Enter fullscreen mode Exit fullscreen mode

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)

  1. Random train/test split on time-series → fake AUC.
  2. Ignoring bid/ask spread → profitable on paper, dead in live.
  3. Naked short options for "higher probability" → one tail event ends the account.
  4. Overfitting IV skew to a single regime → fails at VIX expansion.
  5. 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()
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

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


More from the author: about.me/shaktitiwari | optiontradingwithai.in | WhatsApp: +91 9169650895

Top comments (0)