Daily canary
What drift_check does
python -m pricing.drift_check at 17:00 ET
Mon–Fri, after the 16:35 EOD snapshot. As-of is the last snapshot at or
before 16:40 ET.
Identity-first pass/fail
Pass/fail is identity-first on ATM names
(|K/F − 1| ≤ 5%):
- Reprice:
market_pricevs storedown_price(CRR for American rows, BSM for European). Sigma is inverted with that same engine, so the residual is solver tolerance, not a market-data check. An American row round-trips through the tree; before the American solver existed it was inverted in closed form and repriced on the tree, leaving the early-exercise premium in the residual.--euro-ivrestores that older behaviour for an A/B run. The bands (reprice_abs = 1e-3,reprice_rel = 0,reprice_median_abs = 1e-4) trackCHAIN_CRR_STEPS(currently 51): a tree-depth change invalidates them. - European pairs: Γ_call ≈ Γ_put and vega_call ≈ vega_put at a shared σ (mean of the two inverted IVs), plus PCP on day-close quotes vs
S e^{-qT} − K e^{-rT}. PCP is skipped when either leg used a last-trade (possibly a different instant). - FAIL when median reprice error exceeds its band, or >25% of ATM names fail identities. Too few ATM names (< 20) is also FAIL.
Vendor diffs are diagnostic. Null vendor IV is not a FAIL
(vendor_compare_skipped). Vendor drift can FAIL only with
enough vendor-comparable ATM names. Still fail-loud on no snapshots,
schema errors, foreign roots, empty allowlisted root, filesystem errors.
Off-ATM, the canary reads the landed vol_surface SVI slice
params back (never refits) and round-trips a fixed log-moneyness grid
(k = ±0.05, ±0.10, the OTM halves) at the slices nearest
30 and 90 DTE: price off the slice vol in the forward measure, invert
back with the engine's own solver, band on |Δσ|
(surface_vol_abs = 1e-4 — solver tolerance, like the
reprice bands). The checked session is the previous trading
day: the 12:15 surface build lands session S's smile the next
morning, so S's own partition cannot exist at the 17:00 canary. A
missing partition directory is a skip
(surface_compare_skipped), not a FAIL; a partition that
exists but is empty, unreadable, dated for another session, or cannot
be repriced back to its own vol is fail-loud.
What a FAIL means
Read _meta/drift_check.json failures[] first.
Re-running repair.sh will not change this. The identity
bands live on pricing.drift_check.Thresholds
(reprice_abs = 1e-3, reprice_rel = 0,
reprice_median_abs = 1e-4, fail_frac = 0.25,
min_compare = 20, iv_median_abs = 0.04).
| failures[] looks like | What it actually is |
|---|---|
| only N ATM names to test identities (need ≥ 20) | The as-of chain is too thin after skips (stale, expired, uninvertible, SPY ATM cap). Not “vendor drifted.” Check counts.stale / below_intrinsic / n_priced. Empty or missing snapshots also fail-loud before this. |
| median |price − own_price| exceeds 1e-4 | The stored engine cannot reprice the input on the ATM slice — invert and reprice disagree by more than solver tolerance. American rows round-trip through CRR; if this appeared only after dropping --euro-iv, the tree and the invert disagree. If it appeared with --euro-iv, closed-form BSM itself is off the traded price — stale print that leaked the age filter, or a rate/T/F bug. The band is a property of CHAIN_CRR_STEPS, not of the market. |
| N/M ATM names fail identities (> 25%) | Reprice and/or European pair tests (Γ, vega, PCP on day-close legs) are breaking as a fraction of the ATM set. One name inside band never trips this. PCP is skipped when either leg used a last-trade. |
| median |ΔIV| exceeds 0.04, or vendor fraction > 25% | Vendor diagnostic, and only after ≥ 20 ATM names carry vendor IV. Null vendor IV logs vendor_compare_skipped and still PASSes. A vendor FAIL with holding identities means the vendor moved, not us — still exit 1, still a page. |
| surface: … (not evaluable / |Δσ| exceeds 1e-4 / no rows / no parquet files / have date=) | The landed vol_surface partition for the previous trading day exists but the engine cannot reproduce it off-ATM: empty or unreadable partition, rows dated for another session, failed calendar guard, negative total variance at the grid, or a round-trip residual beyond solver tolerance. Rebuild the session with scripts/build_surface.py --start D --end D --force; if the rebuild lands the same params, the drift is real. A missing partition directory is surface_compare_skipped, not this row. |
Skip counts on the report (stale, below_intrinsic,
uninvertible) are not FAILs by themselves. A jump in
stale is the age filter working. See
red day for whether to pull anything.
Alert path
Cron bounds: --spy-atm-pct 0.05, --max-rows 400
(CRR rows only), --max-trade-age-min 60 (stale last prints
counted, not priced). --r from DRIFT_CHECK_R
when set; otherwise the landed Treasury curve interpolated per
contract. 0.04 survives only as the fallback on a box
where rates_sync has never run. Report:
_meta/drift_check.json + JSONL under
logs/drift_check/. Exit 1 pings Healthchecks
massive-drift-check /fail; optional
DRIFT_ALERT_WEBHOOK (Slack-wrapped if the URL is hooks.slack.com).
Holidays: skip with exit 0 unless --force.