Backtest options strategies with realistic execution, Greeks-aware risk management, and contract-level inventory. Also handles equities and multi-asset portfolios. Built on a Rust compute core.
With Nix:
nix developWithout Nix (Python >= 3.12):
python -m venv .venv && source .venv/bin/activate
make install-devpython scripts/fetch_data.py all --symbols SPYDownloads SPY stock prices and options chains to data/processed/. Supports 104+ symbols. See data/README.md for details.
The canonical SPY parquets are pinned by SHA-256 in scripts/fetch_data.py:
SPY_options.parquet:a7152991b45b81f090f970e945bf88def8093b8ecb9b250e9891cb6d88041f0aSPY_underlying.parquet:847e60a441eb10969d87cd4a6da604257b782d9076168f68d5730b84096c79db
A hash mismatch warning means the source has been updated; published-article reproductions are pinned to the hashes above. Provenance and redistribution posture: data/DATA_NOTICE.md. Verify any copy with python scripts/fetch_data.py verify. Pass --allow-fallback to permit the secondary mirrors (options-data CDN / yfinance) when the canonical source is unreachable. Fallbacks return different bytes and break bit-for-bit reproducibility.
from options_portfolio_backtester import (
BacktestEngine, Stock,
HistoricalOptionsData, TiingoData,
deep_otm_put,
)
# Load data. Parquet is ~5x smaller on disk and ~30x faster to load than CSV.
options_data = HistoricalOptionsData("data/processed/options.parquet")
stocks_data = TiingoData("data/processed/stocks.csv")
# Build the strategy with a canonical preset — the Spitznagel tail-hedge leg
strategy = deep_otm_put(options_data.schema, "SPY")
# Configure the framing: 100% SPY + 0.5% external put budget (Spitznagel)
engine = BacktestEngine({"stocks": 1.0, "options": 0.0, "cash": 0.0},
initial_capital=1_000_000)
engine.use_external_budget(annual_pct=0.005)
engine.stocks = [Stock("SPY", 1.0)]
engine.stocks_data = stocks_data
engine.options_data = options_data
engine.options_strategy = strategy
engine.run(rebalance_freq=1, rebalance_unit="BMS")
# Inspect the results — the dataclass bundles balance, trades, config, and engine version
results = engine.get_results()
print(results.summary())
# {'annual_return': 13.5, 'max_drawdown': -46.4, 'sharpe': 0.72, 'trades': 327, ...}The AQR / allocation-reducing framing is one helper away:
engine.use_allocation(stocks=0.99, options=0.01, cash=0.0)
engine.options_strategy = near_atm_put_protection(schema, "SPY")See research/spitznagel_spy/FRAMINGS.md in the companion repo for the difference between the two framings and what each one produces.
One call produces a single self-contained HTML report with every panel — equity curve vs benchmark, underwater plot, rolling Sharpe/volatility, monthly heatmap, plus the options-specific panels (P&L attribution by leg, premium spend vs budget, crash-window zooms, per-trade P&L on a symlog scale, options exposure):
from options_portfolio_backtester.analytics.tearsheet import build_tearsheet
report = build_tearsheet(
engine.balance,
benchmark_balance=spy_engine.balance, # optional
trade_log=engine.trade_log, # optional, powers the trade panels
budget_annual_pct=0.033, # optional, draws the budget rule
)
report.to_file("tearsheet.html")Charts embed as static SVG when vl-convert-python is installed (the
charts extra) — fully offline — and fall back to interactive vega-embed
otherwise. For the full generic quantstats report (install the reports
extra), every backtest exposes a clean daily-returns series:
import quantstats as qs
qs.reports.html(results.returns, benchmark_results.returns)Instead of building legs manually:
from options_portfolio_backtester import (
Strangle, deep_otm_put, near_atm_put_protection,
)
# Tail-hedge primitives (function style, return a configured Strategy)
spitznagel_leg = deep_otm_put(schema, "SPY") # delta -0.10 to -0.02, DTE 90-180
aqr_leg = near_atm_put_protection(schema, "SPY") # 5% OTM, monthly
# Other presets (class style)
strangle = Strangle(schema, "short", "SPY",
dte_entry_range=(30, 60), dte_exit=7,
otm_pct=5, pct_tolerance=1,
exit_thresholds=(0.2, 0.2))Available presets: deep_otm_put, near_atm_put_protection, Strangle, IronCondor, CoveredCall, CashSecuredPut, Collar, Butterfly.
For equity portfolios without options, use the pipeline API:
from options_portfolio_backtester.engine.pipeline import (
AlgoPipelineBacktester,
RunMonthly, SelectAll, WeighInvVol, LimitWeights, Rebalance,
)
import pandas as pd
prices = pd.read_csv("data/processed/stocks.csv", parse_dates=["date"])
prices = prices.pivot(index="date", columns="symbol", values="adjClose")
bt = AlgoPipelineBacktester(
prices=prices,
initial_capital=1_000_000,
algos=[
RunMonthly(),
SelectAll(),
WeighInvVol(lookback=252),
LimitWeights(limit=0.25),
Rebalance(),
],
)
bt.run()Every component is swappable. Pass them to BacktestEngine(...) or override per-leg.
Signal selectors — which contract to pick from candidates:
FirstMatch(), NearestDelta(target), MaxOpenInterest()
Cost models — commissions and fees:
NoCosts(), PerContractCommission(rate), TieredCommission(tiers), SpreadSlippage(pct)
Fill models — execution price:
MarketAtBidAsk(), MidPrice(), VolumeAwareFill(threshold)
Position sizers — how many contracts:
CapitalBased(), FixedQuantity(qty), FixedDollar(amount), PercentOfPortfolio(pct)
Risk constraints — pre-trade gating:
MaxDelta(limit), MaxVega(limit), MaxDrawdown(max_dd_pct)
At each rebalance date, the engine follows a full liquidation approach:
- Liquidate all options — every open option position is sold at current market price (bid for long, ask for short)
- Compute total capital — cash + stock value (options are zero after liquidation)
- Rebalance stocks — sell all stocks, buy fresh at target allocation (e.g. 97%)
- Buy new options — use the full options allocation (e.g. 3%) to purchase contracts matching entry criteria (DTE, delta, etc.)
This ensures:
- Clean accounting — no stale option value carried across rebalances, no money creation
- Fresh positions — every rebalance picks the best available contracts for current market conditions
- Simple math —
total_capital = cash + stocksat the point of redeployment, no complex delta tracking
Between rebalance dates, positions are held (mark-to-market for balance tracking). If check_exits_daily=True, exit filters run daily but no new entries are made until the next rebalance.
For the Spitznagel leverage model (options_budget parameter), options are funded separately from the stock allocation so {stocks: 1.0, options: 0.005} means 100% equity + 0.5% put budget on top.
Required: the backtest engine imports the Rust core at load time, so it must be built before use. There is no Python fallback.
make rust-build| Benchmark | Time |
|---|---|
| Full options backtest (17-year SPY chain, ~22M rows) | ~4s |
| Stock-only monthly rebalance | ~0.6s |
| Parallel grid sweep (100 configs) | 5-8x vs sequential (Rayon, bypasses GIL) |
(The former Python-engine comparison column was removed along with the Python engine path itself; those numbers are no longer reproducible.)
# SPY stock + options data
python scripts/fetch_data.py all --symbols SPY
# Multiple symbols
python scripts/fetch_data.py all --symbols SPY IWM QQQ --start 2020-01-01 --end 2023-01-01
# FRED macro signals (VIX, GDP, Buffett Indicator, etc.)
python scripts/fetch_signals.py
# Convert OptionsDX format
python scripts/convert_optionsdx.py data/raw/spx_eod_2020.csv --output data/processed/spx_options.csvYou can also bring your own CSVs. Required columns:
- Stocks:
date,symbol,adjClose - Options:
quotedate,underlying,type,strike,expiration,dte,bid,ask,volume,openinterest,delta
make test # default suite (~1300 tests: unit + oracles + convexity)
make test-heavy # data-heavy at-scale correctness tests (needs SPY data)
make verify-articles # reproduce all published-article tables and assert they match
make lint # ruff
make typecheck # mypy (informational; typing debt documented in CI)
make rust-test # Rust unit tests
make rust-bench # criterion performance benchmarksSee tests/README.md for the test-tier model (unit /
oracles / heavy / convexity) and what each tier guards against.
options_portfolio_backtester/
├── core/ # Types: Direction, OptionType, Greeks, Fill, Order
├── data/ # Schema DSL, CSV providers
├── strategy/ # Strategy, StrategyLeg, presets
├── execution/ # CostModel, FillModel, Sizer, SignalSelector
├── portfolio/ # Portfolio, OptionPosition, RiskManager
├── engine/ # BacktestEngine, AlgoPipelineBacktester, StrategyTreeEngine
├── convexity/ # Convexity scoring/backtest (used by presets)
└── analytics/ # BacktestStats, BacktestResults, TradeLog, TearsheetReport, charts
rust/
├── ob_core/ # Backtest loop, stats, execution models, filter parser
└── ob_python/ # PyO3 bindings, parallel sweep, Arrow bridge
40+ composable algos for the AlgoPipelineBacktester. All follow __call__(ctx) -> StepDecision.
Scheduling: RunDaily, RunWeekly, RunMonthly, RunQuarterly, RunYearly, RunOnce, RunOnDate, RunAfterDate, RunAfterDays, RunEveryNPeriods, RunIfOutOfBounds, Or, Not, Require
Selection: SelectAll, SelectThese, SelectHasData, SelectN, SelectMomentum, SelectWhere, SelectRandomly, SelectActive, SelectRegex
Weighting: WeighEqually, WeighSpecified, WeighTarget, WeighInvVol, WeighMeanVar, WeighERC, TargetVol, WeighRandomly
Risk & rebalancing: LimitWeights, LimitDeltas, ScaleWeights, HedgeRisks, Margin, MaxDrawdownGuard, Rebalance, RebalanceOverTime, CapitalFlow, CloseDead, ClosePositionsAfterDates, ReplayTransactions, CouponPayingPosition
Research notebooks and analysis: finance_research.