Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

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.

QueryTraded per-contract records by underlying, August 2021 onward
symbolcontract_countdistinct_contractscontract_days_readabledistinct_contracts_readablecoverage_fromcoverage_to
SPY96510044334439.65 million433.44 thousandJun 2014Oct 2026
NVDA38874801179963.89 million118.00 thousandJun 2014Oct 2026
AAPL2933737859812.93 million85.98 thousandJun 2014Oct 2026
MSFT2552633798322.55 million79.83 thousandJun 2014Oct 2026
KO71050133059710.50 thousand33.06 thousandJun 2014Oct 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 DESC
Run this yourself

For 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.

QueryHow much of a modelled credit survives one volatility point of vega
dte_bandcontract_countavg_credithaircut_pctcredit_retained_pct
01-07 days to expiry3839957.415.994.1
08-14 days to expiry4602417.278.191.9
15-21 days to expiry2530288.83793
22-30 days to expiry315747107.392.7
31-45 days to expiry39860411.597.292.8
46-60 days to expiry15132313.96793
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_band
Run this yourself

The 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.

QueryOne SPY put traced through its final weeks, June 2024 expiry
35 rows (showing 20)
session_dateas_ofmarkvega_per_pointiv_pct
2024-05-01May 10.310.07129.8
2024-05-02May 20.280.06530.5
2024-05-03May 30.210.05130.8
2024-05-06May 60.160.04131.5
2024-05-07May 70.170.04232.2
2024-05-08May 80.160.0432.2
2024-05-09May 90.150.03733.1
2024-05-10May 100.140.03533
2024-05-13May 130.150.03534.7
2024-05-14May 140.130.03135.1
2024-05-15May 150.110.02636.3
2024-05-16May 160.110.02636.4
2024-05-17May 170.10.02436.7
2024-05-20May 200.090.02138
2024-05-21May 210.070.01737.8
2024-05-22May 220.080.01938.9
2024-05-23May 230.10.02239.4
2024-05-24May 240.080.01839.9
2024-05-28May 280.080.01743
2024-05-29May 290.090.01943.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 date
Run this yourself

Its 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.

QueryIn-the-money calls with extrinsic value below the upcoming dividend
symbolcontracts_checkedbelow_dividendbelow_dividend_pctavg_dividend
JNJ62941766.31.221
KO71844161.40.481
PG106152449.40.972
MSFT276841114.80.792
AAPL272219070.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 DESC
Run this yourself

JNJ 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.

QueryContracts that traded once, and contracts still trading at expiry
57 rows (showing 20)
monthcohortcontractstraded_to_expiry_pctsingle_day_pct
2021-09-01Sep 202110925535.3
2021-10-01Oct 2021375041.27.8
2021-11-01Nov 2021347841.18.5
2021-12-01Dec 2021350546.18.5
2022-01-01Jan 2022364048.47.3
2022-02-01Feb 2022368847.212.8
2022-03-01Mar 2022382438.99.4
2022-04-01Apr 2022330943.96.9
2022-05-01May 20224458487.3
2022-06-01Jun 2022417446.86.5
2022-07-01Jul 20223480495.9
2022-08-01Aug 20223042359.1
2022-09-01Sep 2022407751.55.2
2022-10-01Oct 20223168425.2
2022-11-01Nov 2022359056.618.3
2022-12-01Dec 2022433952.814.6
2023-01-01Jan 2023460055.99.5
2023-02-01Feb 2023404754.115.1
2023-03-01Mar 2023506756.813.3
2023-04-01Apr 2023380357.411.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 month
Run this yourself

Of 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

  1. 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.
  2. 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.
  3. 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.