Skip to content

Repository files navigation

vix-tercile-calibrator

Calibrate stop-loss exit slippage per VIX regime from your own options trade logs — validated against your observed fills — with automatic daily CBOE VIX data refresh.

Built for options backtesters who keep finding that their backtest's stop-loss model and their broker's actual fills disagree. The novel part of this toolkit is the reconciliation methodology: it detects when two calibrations are measuring different things (e.g. a rule-driven reprice vs. realized market slippage) and bridges them via an empirical realization ratio k, producing per-VIX-tercile gap constants you can actually trust.


The problem: your backtest has two numbers and they disagree

This happens constantly in options backtesting. You calibrate a stop-loss gap constant one way from your fill log and get, say, 1.10. You calibrate it another way against realized P/L and get 0.20. Both computations "work." Neither is obviously wrong. Which do you book?

In our own production calibration we hit exactly this:

Calibration Result What it was actually measuring
Per-tercile LUT from the trade log's exit prices 1.10 / 1.11 / 1.11 The traders' deterministic reprice rule ("buy back at 2× credit"), not the market
Ground truth from observed fills implied ≈ 0.20 Realized loss as a fraction of credit

The tell-tale symptom: all three terciles land within ~0.01 of each other. A genuine regime signal doesn't look like that — a constant restated three times does.

The reconciliation method

  1. Define one canonical quantity. Here: the gap fraction of credit at trigger, where modeled stopped loss = credit × (1 + g).
  2. Detect the artifact. If logged stop-exit multiples cluster tightly around one value (e.g. median 2.0× credit with tiny IQR), the log likely records a rule-driven reprice, not market realizations. diagnose_reprice_artifact() flags this.
  3. Bridge with k. On the window where ground-truth fills exist:
    k = mean(m_true | observed stops) / mean(m_logged | same window)
    g_tercile = k × mean(m_logged | tercile) − 1
    
  4. Validate out-of-model. Simulate credit × (1 + g_tercile) per stop and compare to actual P/L.

In our case study this took conflicting numbers (a legacy scalar 0.0469, a raw LUT of ~1.10–1.12) to reconciled tercile constants of 0.198 / 0.203 / 0.204, which validated to −0.69% error on stopped fills and −1.89% on the full book against ground truth. The legacy scalar measured slippage beyond an already-booked stop level (different baseline); the raw LUT restated the reprice rule. Only the k-bridged form matched what the engine actually books.

The synthetic example in examples/ reproduces this entire story end-to-end: logged exits cluster at 2.00× (the planted rule), truth realizes ~60% of that, and reconciliation recovers k ≈ 0.60 and g ≈ 0.20 per tercile.

Quickstart

pip install -r requirements.txt

# Run the whole pipeline on the committed SYNTHETIC sample data:
python -m vix_tercile_calibrator.cli calibrate \
    --fills examples/fills.csv --vix examples/vix.csv

python -m vix_tercile_calibrator.cli reconcile \
    --fills examples/fills.csv --truth examples/truth_stopped_fills.csv \
    --vix examples/vix.csv --out reconciled.json

python -m vix_tercile_calibrator.cli costs examples/fills.csv

Or as a library:

from vix_tercile_calibrator import (
    load_vix, compute_gap_lut, reconcile_calibrations, tercile_bounds,
)

vix = load_vix("data/vix.csv")
lut = compute_gap_lut(stops_df, vix, stop_multiple=1.0)

Daily VIX refresh

./scripts/fetch_vix_daily.sh data/vix.csv            # full CBOE history merged in
./scripts/fetch_vix_daily.sh data/vix.csv --min-date 2025-01-01

Idempotent (silent exit 0 when already current), so it is safe to run from cron daily.

Input CSV schemas

Fills (--fills)

column required description
date session date (YYYY-MM-DD or YYYYMMDD)
ticker underlying symbol
credit net credit received per contract (option price units)
exit_reason e.g. stop, target, close, expiry
live_exit_px ✓ (for stops) recorded exit price (price units)
slippage optional recorded entry slippage, price units
pnl_model optional model P/L in $
pnl_obs optional observed P/L in $

Any broker export can be mapped to these columns; nothing else is read.

Truth fills (--truth, optional but recommended)

column description
date session date
ticker underlying symbol
credit net credit received
reason fill reason (stop marks stopped fills)
pnl_obs actual P/L in $
loss_multiple actual stopped-loss multiple of credit (optional; derivable)

VIX (--vix)

Two columns: date,close. Refresh it from CBOE with scripts/fetch_vix_daily.sh.

How terciles work

Each fill gets the most recent VIX close on or before its date (prior session — no lookahead). Tercile bounds are the 33.3/66.7 percentiles of the closes covering your fills: low ≤ b1 < mid ≤ b2 < high. Rows predating the VIX series are dropped rather than forward-filled.

Worked example (synthetic data)

The committed examples/ files are generated by examples/generate_sample_data.py with a fixed seed — no real trades, accounts, or people. The generator plants:

  • 260 VIX sessions with regime-switching dynamics (real terciles),
  • 4,000 synthetic short-premium fills, 12% stopped,
  • logged stop exits clustered at exactly 2.0× credit (the planted reprice rule),
  • ground truth realizing k = 0.60 of the logged multiple with noise.

Running reconcile on it recovers k ≈ 0.60, reconciled g ≈ 0.202 in every tercile, and validation error near zero — while calibrate alone reports the misleading raw g ≈ 1.0 and flags the reprice artifact.

Repository layout

vix_tercile_calibrator/
  vix.py             CBOE download/normalize/merge, idempotent refresh
  calibration.py     tercile assignment (no lookahead), gap LUT computation
  reconciliation.py  artifact detection, realization ratio k, bridged LUT, validation
  costs.py           cost extraction from the generic fills schema
  cli.py             calibrate / reconcile / costs subcommands
scripts/fetch_vix_daily.sh   daily cron-friendly VIX refresh
examples/                    synthetic sample data + generator
tests/                       pytest suite (all synthetic)

Caveats & honest limitations

  • k is calibrated on whatever overlap window your ground truth covers; validation on that window is partially in-sample for the level. Tercile shape comes from the independent full set.
  • If your ground truth only spans one volatility regime (e.g. all fills in the high tercile), low/mid levels remain untested.
  • The artifact detector is a heuristic; always eyeball your logged-multiple distribution.
  • This toolkit calibrates models; it is not investment advice.

License

MIT — see LICENSE.

About

Calibrate options stop-loss exit slippage per VIX regime from your own trade logs, with reconciliation of conflicting calibrations via a realization ratio

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages