Option bots, round 3: vol data, surface analytics, gates, four new bots (2026-09-28)
This round built the missing pieces for option trading, except historical option data (handled separately) and a real broker: - vol-index and macro-event data; - a daily vol-surface summary with options "TA"; - stress and early-assignment risk; - better volatility forecasts, SVI fitting and hedging bands; - four new paper bots; - a replay harness for real chains.
Everything that could be tested walk-forward was, with the usual protocol: choose on H1, judge on H2, alpha t against QQQ, and ship only if the change beats the live rules out of sample.
The short version:
- Nothing added to option_IndexVolBot beat its live rules out of sample.
That covers the term-structure, VVIX and FOMC gates, the unwind, the
Yang-Zhang and GARCH forecasts, the z-scored signal and the tail hedge.
- All stay off except one. The closest miss, a VIX/VIX3M ≤ 1.0 entry gate,
shipped on 2026-09-28 anyway, for its drawdown. See "Shipped anyway"
below.
- The Whalley-Wilmott hedging band ships on option_MispricingBot's
straddle hedge. It is a small improvement.
- The four new bots are live-only and unproven. Their evidence has to come
from the paper record and, later, the replay harness.
What was built
| Piece | Where | What it gives |
|---|---|---|
| Vol indices | utils/vol_indices.py |
^VIX9D/VIX/VIX3M/VIX6M/VVIX/SKEW/VXN levels, term ratio, cached history |
| Macro calendar | utils/macro_calendar.py, macro_events, macrocalendarsnapshot (weekly) |
FOMC statement days (static, from federalreserve.gov, 2006–2027) + CPI and jobs-report dates from FRED |
| Vol surface | utils/vol_surface.py, vol_surface, written after each capture |
Constant-maturity ATM IV (7–180d); 25Δ put/call IV, RR25 and fly; term slope; HAR fair vol and VRP; put/call ratios; GEX; max pain; unusual activity |
| Implied correlation | implied_correlation, same job |
SPY vs the 50 captured names, market-cap weighted |
| Stress and risk | option_math.stress_pnl, options.stress_book, utils/option_risk.py, option_risk, optionrisksnapshot (daily) |
Per bot and underlying: dollar greeks, P&L under spot/vol shocks and a crash (−20%, +30 vol points), max loss, margin |
| Early assignment | PortfolioManager.assign_before_dividends, option_rules.ex_dividend_close_reason, Bot.dividend_threatened_calls |
See the list below |
| Estimators | utils/vol_estimators.py |
Parkinson, Garman-Klass (the simple 0.5 form), Rogers-Satchell, Yang-Zhang; HAR on any daily-variance series; GARCH(1,1); VRP z-score; Whalley-Wilmott band |
| SVI | utils/svi.py, options.svi_surface_fit |
Raw SVI on total variance; Gatheral's butterfly check g(k) ≥ 0 and the calendar check; outliers beyond half the spread in vol terms |
| Parity filter | options.parity_violations |
American bounds S − D − K ≤ C − P ≤ S − Ke^(−rT) at bid/ask. Flagged pairs are dropped as bad data, never traded |
| Scan | utils/mispricing_scan.py, option_mispricing_scan |
Daily ranked list per name: vrp z-score, SVI outliers, parity flags |
| Replay | utils/option_replay.py, scripts/onetime_option_replay_backtest.py |
Backtests over stored chains: fills at recorded bid/ask, settles off the recorded underlying price |
Early-assignment handling: - A short ITM call whose time value is below the next dividend is assigned early on the last session before the ex-date, as a broker would. - Physical bots deliver the shares; cash bots settle at intrinsic value. - The wheel, PMCC, collar, cross-vol and scanner bots buy such a call back two sessions before the ex-date.
Straddle hedge: fixed band vs Whalley-Wilmott
This test uses option_MispricingBot's live rules on the synthetic AAPL
harness (scripts/onetime_option_bots_backtest.py --hedge). Only its cheap
side hedges, so the effect is small.
| Hedge | Full t | H1 t | H2 t | Full max DD |
|---|---|---|---|---|
| Fixed: rehedge to 0 once |Δ|·S > 2% of the book | 0.93 | 0.56 | 0.82 | −43.8% |
| WW, risk aversion 1e-4 (H1 pick) | 1.03 | 0.64 | 0.88 | −44.1% |
| WW 1e-3 | 0.99 | 0.61 | 0.85 | −44.2% |
| WW 1e-2 / 0.1 / 1 | 0.96 / 0.96 / 0.95 | 0.58 / 0.58 / 0.57 | 0.85 / 0.84 / 0.84 | −43.8% |
The H1 pick also wins on H2, so it ships (hedge_ww_risk_aversion=1e-4).
The mechanism is fewer and smaller hedge trades: rehedge only outside
H = (1.5·λ·S·Γ²/γ)^(1/3), and only back to the band's edge. The gain is
0.06 of t, which is worth having but is not an edge in itself.
option_IndexVolBot: new gates, walk-forward
scripts/onetime_index_vol_backtest.py --gates ran two grids on top of the
live rules (0.10Δ, 10% wings, 35 DTE, gap ≥ 3 points):
- Grid A, the vol-curve gates, runs from 2007 because ^VIX3M and ^VVIX
start there. Its split is 2016-11.
- Grid B, the forecast, signal and tail hedge, runs from 2000 with the
split at 2013-06.
The event gate was first run FOMC-only, then rerun with FOMC + CPI once
FRED_API_KEY existed (bullet below the table).
Grid A has 108 variants: VIX/VIX3M cap × VVIX cap × FOMC blackout × unwind. The H1 winner (VIX/VIX3M ≤ 0.95 + VVIX ≤ 110 + 1-day blackout) traded 20 times in H1 at t 4.11, then made t 0.01 on H2. It had filtered itself down to almost no trades. Only 2% of the grid beat the live rules on H2.
Grid B has 45 variants: fair model × signal × tail hedge. The H1 winner (z ≥ 2 on a GARCH forecast, no gap floor) made t 0.56 on H2 against the live 2.64. 9% of the grid beat the live rules on H2.
Every lever on its own, against the live rules:
| Variant | Full t | H1 t | H2 t | Full max DD | Trades |
|---|---|---|---|---|---|
| Live (gap ≥ 3 pts, HAR), 2000–2026 | 4.28 | 3.45 | 2.64 | −6.4% | 103 |
| z ≥ 1.0 only | 1.72 | 2.63 | −0.31 | −20.1% | 173 |
| z ≥ 1.5 only | 2.38 | 2.03 | 1.02 | −11.7% | 119 |
| z ≥ 2.0 only | 1.82 | 1.30 | 0.98 | −7.7% | 73 |
| gap ≥ 3 and z ≥ 1 | 2.89 | 1.87 | 2.35 | −6.3% | 78 |
| gap ≥ 3, Yang-Zhang HAR | 3.31 | 2.26 | 2.36 | −8.0% | 89 |
| gap ≥ 3, GARCH(1,1) | 3.20 | 2.07 | 2.42 | −8.2% | 147 |
| tail hedge 0.25%/month (60-DTE 10Δ puts) | 3.93 | 3.16 | 2.65 | −8.2% | 103 |
| tail hedge 0.5%/month | 2.83 | 2.14 | 2.20 | −15.7% | 103 |
| Live, 2007–2026 window | 3.35 | 1.67 | 3.46 | −6.3% | 80 |
| VIX/VIX3M ≤ 1.0 at entry | 3.87 | 2.84 | 2.65 | −2.7% | 56 |
| VIX/VIX3M ≤ 0.95 | 1.93 | 0.89 | 1.88 | −6.0% | 40 |
| VVIX ≤ 130 | 3.07 | 1.59 | 3.15 | −6.3% | 70 |
| FOMC blackout, 1 session | 3.26 | 1.57 | 3.45 | −6.4% | 78 |
| unwind at VIX/VIX3M > 1.0 | 1.92 | 0.86 | 1.94 | −6.3% | 117 |
| unwind at > 1.05 | 3.18 | 2.29 | 2.22 | −5.8% | 95 |
What this says:
- The z-scored signal is worse than the raw 3-point gap on SPY. This is the opposite of the textbook argument ("IV always sits above RV, so z-score the spread"). The live rule already answers that argument: it demands a level, IV at least 3 points above the forecast, rather than "IV > forecast". The z-score instead fires whenever the gap is unusual for the past year, which in a calm year includes small absolute gaps that carry no premium.
- This matters for
option_MispricingScanBot, which was specified around the z-score. It ships as specified, and this table is its prior. - Yang-Zhang and GARCH do not beat close-to-close HAR here. HAR is already a good forecaster of index vol. Range data adds precision to measuring today's vol, not to forecasting next month's.
- A VIX/VIX3M ≤ 1.0 entry gate is the closest miss. On the 2007–2026 window it halves the drawdown (−2.7% vs −6.3%) and lifts full-window and H1 t. It loses on H2 (2.65 vs 3.46), so under the protocol it does not ship.
- It is the first thing to revisit when the live record exists.
- It is also the natural candidate if drawdown ever matters more than t. That is what happened; see "Shipped anyway" below.
- Unwinding on inversion hurts. It closes condors at the worst marks, just before vol mean-reverts.
- The tail hedge buys negative correlation at the cost of drawdown in calm years. At 0.25%/month the H2 t is flat, but the max DD is worse.
- The FOMC blackout changes almost nothing. A 35-DTE condor holds through one or two meetings whatever day it opens.
- Adding CPI days (rerun with
FRED_API_KEY) changes nothing either. - Grid A's H1 winner is the same variant and makes the same H2 t of 0.01.
- On its own, a FOMC+CPI blackout of 1 session scores H1 1.38 and H2 3.24; 2 sessions score H1 1.47 and H2 3.76. The live rules score 1.67 and 3.46.
- The 2-session blackout wins on H2 but loses on H1, where the choice is made, so it is not picked.
Shipped anyway: VIX/VIX3M ≤ 1.0 (2026-09-28)
option_IndexVolBot.RULES now carries max_term_ratio=1.0. While the vol
curve is inverted (VIX above VIX3M), no new condor opens. This is a
deliberate override of the walk-forward protocol: the gate lost on H2.
It trades H2 alpha t for drawdown, on the 2007–2026 window:
| Gate | Ungated live rules | |
|---|---|---|
| H2 t | 2.65 | 3.46 |
| H1 t | 2.84 | 1.67 |
| Full t | 3.87 | 3.35 |
| Max drawdown | −2.7% | −6.3% |
| Trades | 56 | 80 |
Both halves stay at t ≥ 2 with the gate, which the ungated rules do not manage on H1.
The mechanism is plain: an inverted curve means the market is already pricing near-term stress, and a 35-DTE short condor opened then is the trade that makes the drawdown.
A missing ^VIX3M quote blocks the entry. Revisit this with the live record, since this choice has no out-of-sample support.
The live decision path, re-baselined (round 4, 2026-09-28)
Round 4 moved the bot's logic into one pure function,
utils/option_strategies.decide_indexvol. The live bot, the replay over stored
chains and a synthetic chain now all execute it.
scripts/onetime_index_vol_backtest.py --decide runs it on a synthetic SPY
chain built from the same model as the fast simulator above:
- a $1 strike grid, with contracts listed down to a $0.01 mid;
- the harness's fills;
- strikes chosen by select_iron_condor;
- sizing from cash after margin.
It found a live-vs-backtest drift. The live bot fitted HAR on 5 years of
closes (getYFData(period="5y")). Every backtest, including the walk-forward
that picked these rules, fits it on an expanding window from 1999. On the
2007–2026 window that difference alone was large:
| Live decision path | Full t | H1 t | H2 t | Max DD |
|---|---|---|---|---|
| gated, HAR on 5 years | 1.35 | 0.18 | 1.81 | −5.6% |
| gated, HAR on all history | 4.13 | 2.66 | 3.26 | −2.4% |
The live bot now uses all history (LiveMarket.closes, period="max"), and
replay and the synthetic chain do the same.
With that fixed, the decision path next to the fast simulator, 2007–2026 (split 2016-11):
| Variant | Full t | H1 t | H2 t | Max DD | Trades |
|---|---|---|---|---|---|
| fast sim, live rules (gate on) | 3.87 | 2.84 | 2.65 | −2.7% | 56 |
| decide path, live rules (gate on) | 4.13 | 2.66 | 3.26 | −2.4% | 59 |
| fast sim, ungated | 3.35 | 1.67 | 3.46 | −6.3% | 80 |
| decide path, ungated | 2.44 | 0.50 | 3.88 | −7.7% | 80 |
Result for the shipped rules: they reproduce within the ±0.3 t acceptance band on full and H1 (+0.26, −0.18), and do better on H2 and drawdown.
The ungated rules do not reproduce: H1 falls from 1.67 to 0.50, with 2008 in that half. What differs is strike selection on a smile-consistent chain, wings that stop at the last quoted strike, and IVs solved per contract. They matter most in a crash.
What this means for the gate: on the path the bot actually trades, it beats the ungated rules on full t, H1 t and drawdown, and loses only H2 (3.26 vs 3.88). The drawdown-over-t choice looks better here than it did on the fast simulator.
The research grids (--tune, --gates) stay on the fast simulator: the decide
path takes about 3 s per simulated year. Treat their rankings as relative;
confirm a winner with --decide before shipping it.
The new bots
All four are paper, $100k, and in values.yaml unsuspended. None can be backtested yet, because per-name option history does not exist. Their record starts now.
| Bot | Trades | Why it might work | What would sink it |
|---|---|---|---|
option_CrossVolBot (15:50) |
10Δ condors on up to 5 of the 50 names furthest above their Yang-Zhang HAR forecast (gap ≥ 5 pts, no earnings before expiry) | The AAPL mispricing bot found the gap real but lost it to single-stock jumps. Five names at 4% risk each diversify the jumps | Single-name spreads (far wider than SPY's); correlated sell-offs hit every name at once |
option_MispricingScanBot (16:00) |
Both directions at |z| ≥ 2 of the name's own gap. Rich: 16Δ condor, SVI-checked strikes. Cheap: straddle hedged in a WW band. Vega budget 1% of equity per vol point; crash cap 15% | The user's specification: a forward forecast, the VRP z-score, surface no-arbitrage filters, parity as a data filter, vega sizing, a term-structure unwind | On SPY the z-score signal loses to the raw gap (table above). Single names only score after 60 days of their own surface |
option_EarningsCrushBot (14:30 and 19:30) |
ATM iron butterflies, wings at 1.5 implied moves, entered the session before a report whose two-expiry implied move is ≥ 1.25× its historical RMS move; closed after the open | Large caps price earnings moves above what they deliver, on average | The average hides fat tails: the one report that moves 3× costs several wins |
option_DispersionBot (15:55) |
Short SPY iron fly vs long straddles on the 10 largest names, vega-weighted N_i = |V_I|·w_i/V_i, when implied correlation is ≥ its 80th percentile | Rich implied correlation mean-reverts; the long straddles pay when members move apart | Correlation → 1 in a crash; 11 structures of rounding error on $100k. Trades nothing until 60 days of history exist |
option_CrossVolBot on real chains: paused (2026-10-01)
The 50-name DoltHub import made a real replay possible.
Setup:
- 25 names with a full history, 2025-01-02 to 2026-09-29 (434 days).
- Fills at the recorded bid/ask.
- Fair vol was fitted only on closes up to each day.
- The shortlist is every loaded name. Live shortlists by yesterday's scan; with every chain loaded, ranking all names makes the same choice.
- Two ways of pricing the expiry:
- Listed: only the expiries DoltHub recorded.
- Bot's own: the bot's own ~30-day expiry, priced off each day's surface (--live-expiries). The data does not list that expiry.
The kill rule was fixed before the result: t < −2 on listed expiries kills it, and the 8-point gap is a robustness check, not a rescue.
| Expiries | Variant | Alpha/yr | t | H1 t | H2 t | Max DD | Trades |
|---|---|---|---|---|---|---|---|
| Listed | live rules (gap ≥ 5) | −10.2% | −3.56 | −2.27 | −2.81 | −14.3% | 34 |
| Listed | gap ≥ 8 | −5.1% | −2.76 | −1.25 | −2.45 | −7.5% | 18 |
| Bot's own (surface) | live rules | −12.0% | −3.61 | −2.65 | −2.56 | −16.7% | 89 |
| Bot's own (surface) | gap ≥ 8 | −8.1% | −3.77 | −1.60 | −3.53 | −11.7% | 52 |
- Every variant is negative, with beta under 0.1. It is not losing to the market. It loses on the trades themselves.
- Raising the gap does not help. On listed expiries the 8-point gap loses less only because it trades half as often. On the bot's own expiry it loses more (t −3.77).
- What happened: paused (
suspend: true). It never traded live, and its book is $100k cash. - Caveats:
- The 25 names were picked by today's size. Survivorship flatters short vol, so the real result is, if anything, worse.
- The replays solved IV with today's T-bill rate. That is close to the 2025–26 rate.
Where the loss comes from: the signal or the spread? (2026-10-01)
The same replays were rerun twice, once filling every option leg at mid and
once at the recorded bid/ask (--fill mid), with a per-trade log
(--trades-csv). Each replayed day is now priced at its own T-bill rate.
| CrossVol (25 names, 2025–26, listed) | IndexVol (SPY 2019–26, live expiries) | |
|---|---|---|
| t at bid/ask | −3.59 (34 trades) | −0.71 (36 trades) |
| t at mid | −0.80 (53 trades) | −0.58 (36 trades) |
| Spread paid / total loss | $14,617 / $13,659 | $1,220 / $6,340 |
| IV − realized vol, mean | +1.1 pts | +2.5 pts (median +4.4), 81% of trades win |
| Fair (HAR) − realized, mean | −6.3 pts | −1.5 pts |
| Trades with a report inside the hold | 0 | – |
CrossVol: the spread costs about the whole loss, and nothing is left under it. - Before the spread, the 34 trades made +$302 in total. - The "IV ≥ forecast + 5 pts" gap mostly marks a forecast that runs low. On the names it selects, the HAR forecast came in 6 points under the vol the stock then realized, while IV was only 1 point over it. - The earnings filter worked: no trade held a report. - A round trip cost about $430 per condor, roughly 10% of the $4k at risk.
IndexVol: the premium is real, but the condor gives it back in the tails. - Three of 36 trades lose more than the other 33 make: 2020-05-06 (−$6.5k), 2025-03-24 (−$4.6k) and 2022-03-14 (−$2.9k). - In two of those three, realized vol was below IV. The rallies ran through the short call, which is the call-skew problem in index-vol-real-surface-2026-09.md.
Verdict: better execution (limit orders at mid) would not rescue either strategy. Selling single-name or SPY vol through these structures has no edge on real prices.
What still limits all of this
- No per-name option history, until the capture accumulates or history
is imported. The replay harness is ready for it: it only needs the rows in
option_quotes. - The SPY skew is calibrated on one day (see
index-vol-2026-09.md).
vol_surface.rr25_30now records it daily, so it can be recalibrated against months of real data. - CPI and jobs-report dates come from FRED (
FRED_API_KEY, in the cluster secret since 2026-09-28). If the key goes missing,macrocalendarsnapshotexits 1 and the calendar stays FOMC-only.
Reproduce
uv run python scripts/onetime_option_bots_backtest.py --hedge # WW vs fixed band
uv run python scripts/onetime_index_vol_backtest.py --gates # grids A and B
FRED_API_KEY=... uv run python scripts/onetime_index_vol_backtest.py --gates # with CPI days
uv run python scripts/onetime_option_replay_backtest.py --strategy crossvol # once option_quotes has history
python -m tradingbot.optionchainsnapshot --backfill # rebuild vol_surface / scan from option_quotes