What Breaks an Options Backtest
What breaks an options backtest is the entry price and the contract list. Reprice every entry one volatility point and see how much modelled credit survives.
What breaks an options backtest is rarely the trade logic. The damage comes from the entry price the test assumed and from the contracts the dataset let it see. Both are measurable, and this page measures them on daily per-contract records running from August 2021, with the exact SQL under every panel.
Why an options backtest needs per-contract data
A stock backtest works off one row per ticker per day. The ticker persists, the row is always there, and what you traded yesterday is what you can trade today. Options have no such continuity. The tradable object is a contract with a strike and an expiration date, and it stops existing on a known day. A single underlying carries thousands of live contracts at once, and a different set of them next week.
So an options backtest needs a per-contract record through time: the strike, the expiration, the days left, the implied volatility, and the greeks, for every contract on every session it traded. Implied volatility is the volatility input that makes a pricing model return the contract's observed price. Sourcing it is its own project, covered in historical implied volatility data. The panel below shows the shape the record arrives in.
| symbol | contract_count | distinct_contracts | contract_days_readable | distinct_contracts_readable | coverage_from | coverage_to |
|---|---|---|---|---|---|---|
| SPY | 9651004 | 433443 | 9.65 million | 433.44 thousand | Jun 2014 | Oct 2026 |
| NVDA | 3887480 | 117996 | 3.89 million | 118.00 thousand | Jun 2014 | Oct 2026 |
| AAPL | 2933737 | 85981 | 2.93 million | 85.98 thousand | Jun 2014 | Oct 2026 |
| MSFT | 2552633 | 79832 | 2.55 million | 79.83 thousand | Jun 2014 | Oct 2026 |
| KO | 710501 | 33059 | 710.50 thousand | 33.06 thousand | Jun 2014 | Oct 2026 |
The exact SQL behind every number
SELECT
underlying_symbol AS symbol,
count() AS contract_count,
countDistinct(ticker) AS distinct_contracts,
formatReadableQuantity(count()) AS contract_days_readable,
formatReadableQuantity(countDistinct(ticker)) AS distinct_contracts_readable,
formatDateTime(min(date), '%b %Y') AS coverage_from,
formatDateTime(max(date), '%b %Y') AS coverage_to
FROM global_markets.options_greeks
WHERE underlying_symbol IN ('SPY', 'AAPL', 'MSFT', 'NVDA', 'KO')
AND iv_converged = 1
AND volume > 0
GROUP BY symbol
ORDER BY contract_count DESCFor SPY alone that is 9.65 million traded contract days across 433.44 thousand distinct contracts, between Jun 2014 and Oct 2026. A test written against one daily price series for the underlying has none of this and cannot know what it would have paid for anything. The equity version of the discipline sits in how to backtest a trading strategy. Everything below is the part specific to contracts.
What one volatility point does to a modelled credit
Vega is the dollar change in a contract's price for a one point move in implied volatility. It is the cleanest way to price an entry assumption. A backtest that enters at a modelled mark is claiming it sold at exactly the volatility the model fit, and nobody sells at the fit. The bid sits under the mark, and the volatility a seller actually gets is a notch under the model's.
So reprice it. Take every near the money contract day in the record, move implied volatility one point against the seller using that contract's own vega, and ask how much of the modelled credit is left standing.
| dte_band | contract_count | avg_credit | haircut_pct | credit_retained_pct |
|---|---|---|---|---|
| 01-07 days to expiry | 383995 | 7.41 | 5.9 | 94.1 |
| 08-14 days to expiry | 460241 | 7.27 | 8.1 | 91.9 |
| 15-21 days to expiry | 253028 | 8.83 | 7 | 93 |
| 22-30 days to expiry | 315747 | 10 | 7.3 | 92.7 |
| 31-45 days to expiry | 398604 | 11.59 | 7.2 | 92.8 |
| 46-60 days to expiry | 151323 | 13.96 | 7 | 93 |
The exact SQL behind every number
SELECT
multiIf(days_to_expiry <= 7, '01-07 days to expiry',
days_to_expiry <= 14, '08-14 days to expiry',
days_to_expiry <= 21, '15-21 days to expiry',
days_to_expiry <= 30, '22-30 days to expiry',
days_to_expiry <= 45, '31-45 days to expiry',
'46-60 days to expiry') AS dte_band,
count() AS contract_count,
round(avg(toFloat64(option_close)), 2) AS avg_credit,
round(100 * avg(toFloat64(vega) / toFloat64(option_close)), 1) AS haircut_pct,
round(100 - 100 * avg(toFloat64(vega) / toFloat64(option_close)), 1) AS credit_retained_pct
FROM global_markets.options_greeks
WHERE underlying_symbol IN ('SPY', 'AAPL', 'MSFT', 'NVDA', 'KO')
AND date >= '2021-08-01'
AND iv_converged = 1
AND volume > 0
AND days_to_expiry BETWEEN 1 AND 60
AND toFloat64(option_close) >= 0.50
AND toFloat64(vega) > 0
AND toFloat64(underlying_close) > 0
AND abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) < 0.05
GROUP BY dte_band
ORDER BY dte_bandThe shortest band keeps 94.1% of the modelled credit after that single point, on an average mark of $7.41. The longest band keeps 93%, on $13.96. The chart draws the whole curve. One point is a polite move: quoted spreads on these contracts are routinely wider than that in volatility terms, and a thin chain is wider again, which is the subject of backtesting in illiquid markets. An edge of 5.9% of premium per leg is exactly one volatility point wide, and a test that never reprices cannot say which side of that line its result sits on.
Averages hide the mechanism, so here is one contract on its own: the SPY put expiring in June 2024 with the longest trading history in the record, traced through its final two months.
| session_date | as_of | mark | vega_per_point | iv_pct |
|---|---|---|---|---|
| 2024-05-01 | May 1 | 0.31 | 0.071 | 29.8 |
| 2024-05-02 | May 2 | 0.28 | 0.065 | 30.5 |
| 2024-05-03 | May 3 | 0.21 | 0.051 | 30.8 |
| 2024-05-06 | May 6 | 0.16 | 0.041 | 31.5 |
| 2024-05-07 | May 7 | 0.17 | 0.042 | 32.2 |
| 2024-05-08 | May 8 | 0.16 | 0.04 | 32.2 |
| 2024-05-09 | May 9 | 0.15 | 0.037 | 33.1 |
| 2024-05-10 | May 10 | 0.14 | 0.035 | 33 |
| 2024-05-13 | May 13 | 0.15 | 0.035 | 34.7 |
| 2024-05-14 | May 14 | 0.13 | 0.031 | 35.1 |
| 2024-05-15 | May 15 | 0.11 | 0.026 | 36.3 |
| 2024-05-16 | May 16 | 0.11 | 0.026 | 36.4 |
| 2024-05-17 | May 17 | 0.1 | 0.024 | 36.7 |
| 2024-05-20 | May 20 | 0.09 | 0.021 | 38 |
| 2024-05-21 | May 21 | 0.07 | 0.017 | 37.8 |
| 2024-05-22 | May 22 | 0.08 | 0.019 | 38.9 |
| 2024-05-23 | May 23 | 0.1 | 0.022 | 39.4 |
| 2024-05-24 | May 24 | 0.08 | 0.018 | 39.9 |
| 2024-05-28 | May 28 | 0.08 | 0.017 | 43 |
| 2024-05-29 | May 29 | 0.09 | 0.019 | 43.1 |
The exact SQL behind every number
SELECT
date AS session_date,
formatDateTime(date, '%b %e') AS as_of,
round(toFloat64(option_close), 2) AS mark,
round(toFloat64(vega), 3) AS vega_per_point,
round(100 * toFloat64(implied_volatility), 1) AS iv_pct
FROM global_markets.options_greeks
WHERE ticker =
(
SELECT ticker
FROM global_markets.options_greeks
WHERE underlying_symbol = 'SPY'
AND expiration_date >= '2024-06-01'
AND expiration_date < '2024-07-01'
AND startsWith(lower(option_type), 'p')
AND iv_converged = 1
AND volume > 0
GROUP BY ticker
ORDER BY count() DESC, sum(volume) DESC
LIMIT 1
)
AND date >= '2024-05-01'
AND date < '2024-07-01'
AND iv_converged = 1
ORDER BY dateIts mark moved from $0.31 on May 1 to $0.01 on Jun 20, with implied volatility at 29.8% at the start of the trace and 189.6% at the end. Vega on that first session was $0.071 per volatility point. Against a mark of $0.31, that is the entry assumption measured in dollars, on one real contract, with no averaging anywhere.
Early assignment, the quiet subsidy to short premium
American style equity options can be exercised by the holder on any session before expiry. That is the seller's problem, not the buyer's. A backtest that closes every short position at a modelled mark on its planned exit date has assumed, without stating it anywhere, that this never happens. It happens in two recognisable places.
The first is the session before an ex dividend date. A call holder who exercises early gives up the contract's extrinsic value, the part of the price above intrinsic value, and collects the cash dividend instead. When extrinsic value sits below the dividend, early exercise is the arithmetically better choice for the holder, and the short call wakes up assigned. The panel below runs that comparison on every in the money call across five dividend paying household names.
| symbol | contracts_checked | below_dividend | below_dividend_pct | avg_dividend |
|---|---|---|---|---|
| JNJ | 629 | 417 | 66.3 | 1.221 |
| KO | 718 | 441 | 61.4 | 0.481 |
| PG | 1061 | 524 | 49.4 | 0.972 |
| MSFT | 2768 | 411 | 14.8 | 0.792 |
| AAPL | 2722 | 190 | 7 | 0.244 |
The exact SQL behind every number
SELECT
symbol,
count() AS contracts_checked,
countIf(extrinsic < dividend) AS below_dividend,
round(100 * countIf(extrinsic < dividend) / count(), 1) AS below_dividend_pct,
round(avg(dividend), 3) AS avg_dividend
FROM
(
SELECT
g.underlying_symbol AS symbol,
g.ticker AS contract,
d.ex_dividend_date AS ex_date,
any(d.cash_amount) AS dividend,
argMax(toFloat64(g.option_close)
- (toFloat64(g.underlying_close) - toFloat64(g.strike_price)),
g.date) AS extrinsic
FROM global_markets.options_greeks AS g
INNER JOIN
(
SELECT
ticker,
ex_dividend_date,
max(toFloat64(cash_amount)) AS cash_amount
FROM global_markets.stocks_dividends
WHERE ticker IN ('KO', 'MSFT', 'AAPL', 'JNJ', 'PG')
AND ex_dividend_date >= '2021-09-01'
AND ex_dividend_date < '2026-09-01'
GROUP BY ticker, ex_dividend_date
) AS d ON d.ticker = g.underlying_symbol
WHERE startsWith(lower(g.option_type), 'c')
AND g.iv_converged = 1
AND g.volume > 0
AND g.days_to_expiry BETWEEN 1 AND 60
AND toFloat64(g.underlying_close) > toFloat64(g.strike_price)
AND g.date < d.ex_dividend_date
AND g.date >= d.ex_dividend_date - 4
GROUP BY symbol, contract, ex_date
)
GROUP BY symbol
ORDER BY below_dividend_pct DESCJNJ leads the five: 417 of 629 in the money call positions checked, or 66.3%, carried extrinsic value below the upcoming dividend, which averaged $1.221 across the window. The bottom name of the five, AAPL, reads 7%. The second place assignment shows up is expiry itself, where an in the money contract becomes a stock position with a weekend gap attached, which when short options get assigned early covers case by case.
To spot the assumption in someone else's result, ask for the trade log. If every exit price equals a modelled mark on the planned exit date, and not one trade in five years closed at intrinsic value or turned into shares, the test has no assignment handling at all. A short premium win rate sitting within a point of the modelled probability of expiring out of the money is the same tell, which iron condor win rate and expectancy works through.
Contract survivorship, or the contracts your test never saw
The last assumption is the one nobody writes down: the contracts a test traded are the contracts the record happened to keep. A per-contract row exists for a session when that contract traded. Anything that stops trading leaves the panel, and a backtest requiring a price on both the entry day and the exit day quietly keeps only the contracts that traded all the way through. Those are the liquid ones, and liquid contracts behave better than the average contract.
The panel below groups every SPY contract by the month it first traded, then asks what became of each cohort.
| month | cohort | contracts | traded_to_expiry_pct | single_day_pct |
|---|---|---|---|---|
| 2021-09-01 | Sep 2021 | 10925 | 53 | 5.3 |
| 2021-10-01 | Oct 2021 | 3750 | 41.2 | 7.8 |
| 2021-11-01 | Nov 2021 | 3478 | 41.1 | 8.5 |
| 2021-12-01 | Dec 2021 | 3505 | 46.1 | 8.5 |
| 2022-01-01 | Jan 2022 | 3640 | 48.4 | 7.3 |
| 2022-02-01 | Feb 2022 | 3688 | 47.2 | 12.8 |
| 2022-03-01 | Mar 2022 | 3824 | 38.9 | 9.4 |
| 2022-04-01 | Apr 2022 | 3309 | 43.9 | 6.9 |
| 2022-05-01 | May 2022 | 4458 | 48 | 7.3 |
| 2022-06-01 | Jun 2022 | 4174 | 46.8 | 6.5 |
| 2022-07-01 | Jul 2022 | 3480 | 49 | 5.9 |
| 2022-08-01 | Aug 2022 | 3042 | 35 | 9.1 |
| 2022-09-01 | Sep 2022 | 4077 | 51.5 | 5.2 |
| 2022-10-01 | Oct 2022 | 3168 | 42 | 5.2 |
| 2022-11-01 | Nov 2022 | 3590 | 56.6 | 18.3 |
| 2022-12-01 | Dec 2022 | 4339 | 52.8 | 14.6 |
| 2023-01-01 | Jan 2023 | 4600 | 55.9 | 9.5 |
| 2023-02-01 | Feb 2023 | 4047 | 54.1 | 15.1 |
| 2023-03-01 | Mar 2023 | 5067 | 56.8 | 13.3 |
| 2023-04-01 | Apr 2023 | 3803 | 57.4 | 11.1 |
The exact SQL behind every number
SELECT
toString(toStartOfMonth(first_seen)) AS month,
formatDateTime(toStartOfMonth(first_seen), '%b %Y') AS cohort,
count() AS contracts,
round(100 * countIf(min_dte <= 1) / count(), 1) AS traded_to_expiry_pct,
round(100 * countIf(observed_days = 1) / count(), 1) AS single_day_pct
FROM
(
SELECT
ticker,
min(date) AS first_seen,
min(days_to_expiry) AS min_dte,
count() AS observed_days
FROM global_markets.options_greeks
WHERE underlying_symbol = 'SPY'
AND date >= '2021-09-01'
AND date < '2026-09-01'
AND expiration_date < '2026-09-01'
AND volume > 0
GROUP BY ticker
)
WHERE first_seen < '2026-06-01'
GROUP BY month, cohort
ORDER BY monthOf the contracts first traded in Sep 2021, 53% were still printing volume inside their final day of life, and 5.3% traded on exactly one session ever. The most recent cohort in view, May 2026, reads 59% and 10%. Every contract in the gap is one an entry rule could have picked and an exit rule could not have priced.
Detection comes from the output again. A published options backtest can state how many candidate contracts its universe held and how many it dropped for missing data. A test reporting zero skipped entries over five years of real chains is describing a universe that was filtered before the test ran, not a universe without holes. The look ahead variant of the same mistake, where the filter uses information from after the entry, is laid out in look ahead bias in backtesting.
Three questions to ask of any options backtest
- What price did every fill use, and what happens to the result when each entry is repriced one volatility point against it? The answer is a second equity curve, not a sentence.
- Which exits were forced rather than chosen? Count the early assignments and the in the money expiries in the log, then check what price each one used.
- Where did the contract universe come from, and how many candidates were dropped for missing data? That count is the survivorship receipt.
A results page offering an adjustable slippage setting and no fill model has answered none of the three. A setting is an input. All three questions are about outputs.
Data notes and the limits of these panels
Every panel filters to converged implied volatility and to sessions where the contract traded, which is also the honest limit of the survivorship panel: it measures when a contract stopped trading, not when it stopped existing. Vega is used as the record reports it, per one volatility point, and the repricing holds everything else fixed, which makes it a floor on the entry assumption rather than a fill model. The near the money filter keeps strikes within 5% of the underlying close and marks at or above $0.50, since the vega to premium ratio on a penny contract is arithmetic rather than information.
FAQ
Why is an options backtest harder than a stock backtest?
A stock has one continuous price series. An options strategy trades contracts with a fixed expiration and a finite life, so the test needs a per-contract record through time plus the greeks to price its entry assumption. The universe being selected from changes every week.
Does one volatility point of slippage really matter?
It matters in proportion to premium. On near the money contracts in this record, one volatility point of vega costs 5.9% of the mark in the shortest expiry band, before any crossing of the bid to ask spread.
How do I account for early assignment in an options backtest?
Flag every short in the money call on the session before an ex dividend date, compare its extrinsic value with the cash dividend, and treat the position as assigned when extrinsic is lower. Apply the same check to every in the money contract at expiry.
What is contract survivorship bias in options data?
It is the bias from testing only contracts that have a usable price on every date the strategy needs one. Contracts that stop trading drop out of the sample, and they are the illiquid ones, so the surviving sample looks easier to trade than the real chain was.
Every panel here carries the SQL that produced it, so these numbers re-derive years from now instead of ageing into claims. To run the same repricing on a different underlying, ask the question in plain English on the Strasmore terminal.