Skip to content

KB/framework

Drawdown-Risk Lens (20-day, transparent weighted z-score composite)

Last verified

A probabilistic drawdown-risk gauge over the credit / inflation / rate LEVELS the full-history study flagged for the platform’s multi-week drawdown lane, plus a validated positioning input — dealer net gamma. Renders as the “Drawdown-Risk Lens · 20d” block in the regime card with a 0–100 score, a low / elevated / high band, and the per-leg contributions. The 15-year re-audit (U1, 2026-07-13) upgraded it to a validated cross-regime drawdown-severity gauge — but the edge is between-regime severity, never within-episode timing (see the caveats).

This is not a directional predictor, not a timing trigger, and not the validated logistic from the research. It is a transparent weighted z-score composite, chosen deliberately for explainability and graceful degradation over fitted accuracy.

What it measures

Each of the nine macro levels is standardized against its trailing baseline (default ~2 trading years), oriented by economic risk-direction, and averaged into a single number. The dealer-gamma leg adds a positioning read on top. Higher = more stress + more fragile dealer positioning relative to the recent norm; lower = calmer.

The dealer-gamma positioning leg

An important positioning input in the platform’s drawdown research is dealer net-gamma positioning (a “dealer-gamma analytics” feed). When option dealers sit at historically low (more-negative) net gamma, they are short gamma — forced to sell into a falling market and buy into a rising one, which amplifies drawdowns. The leg is net_gex_rank: the 252-day trailing percentile rank of the aggregate dealer net-gamma exposure. A low rank (historically negative gamma) reads as elevated drawdown risk; a high rank (positive gamma, stabilizing dealers) reads as calmer.

The discovery-era read (rank-vs-drawdown IC ≈ +0.25 non-overlapping, on two episodes) did not survive the 15-year re-validation. The U2 re-audit (2026-07-13) halved the non-OL rank-IC to ≈ +0.10 to +0.11 at 15y power (boot-CI lower bound ≤ 0), and each macro leg (nfci / hy_oas / gz_spread / ofr_fsi) individually out-ICs it — so on rank-IC the gamma leg is now mid-pack, not the strongest. What did transfer (and to the 2011–2020 out-of-sample span) is the quintile tail-lift: ~5–6× Q20/Q80 separation on the −5%-drawdown days, monotone. So the leg’s value is tail-capture + positioning-class diversification + freshness, not day-ordering rank-IC. Its weight was re-sized down from 2× to 1.5× accordingly (the residual premium pays for tail-lift, not IC; 1.0 is defensible on strict IC parity).

The rank needs a trailing-history warm-up; on a cold start (under ~6 months of gamma history) the leg abstains rather than report a misleading mid-rank.

Why this shape, not a fitted model

The full-history study (.research/SYNTHESIS.md) validated the multi-week drawdown signal as a parsimonious L2 logistic over seven of these macro features (the composite later added gz_spread + ofr_fsi_funding). That model is sklearn-based, and the platform’s prod image deliberately ships no machine-learning runtimescikit-learn and its peers live in requirements-dev.txt and the no-Dockerfile-reference lint (verify-docs.sh:27) blocks them from ever being COPYed into a production container.

The deployable shape is therefore the transparent composite. Trade-offs, on the record:

The nine macro inputs + risk-orientation

The orientation table lives in app/signals/drawdown_risk.py:_FEATURE_ORIENTATION and is documented inline with the rationale. Summary:

Feature Orientation Reading
hy_oas +1 High-yield credit-spread widening = credit stress. The official series publishes next-morning (T-1 on display); the leg prefers a same-day level nowcast of the print (the stale value + a fixed coefficient times today’s duration-isolated high-yield ETF excess return) when available — the leg’s input_source field says which input fed it (nowcast / stale_print). Input-freshening only: weight, orientation, and baseline are unchanged.
ebp +1 Excess bond premium = above-fundamentals default-risk compensation
nfci +1 Positive NFCI = tighter-than-average financial conditions
real_yield_10y +1 Higher real yields = tighter policy / valuation pressure
t10y3m_spread −1 Curve inversion (low/negative) precedes recessions — but by 12–18 months, mis-horizoned against the 10–20-day target. Zero-weighted (the only wrong-signed leg; see the weighting note above); orientation shown for context but inert on the score.
breakeven_5y +1 Elevated inflation expectations → tightening risk (regime-specific)
breakeven_10y +1 Same — long-end inflation expectations
gz_spread +1 Wider Gilchrist-Zakrajšek parent credit spread = credit stress (weight 0.5 — see the double-count split note above; ebp is its predicted-default subcomponent)
ofr_fsi_funding +1 Higher OFR funding-stress subindex = funding-market stress (weight 1.5 — the highest forward-drawdown IC of any tested component)

A +1 feature contributes +1·z to the mean (positive z = up = more risk). A −1 feature contributes −1·z (so a deeply inverted curve, where z is very negative, still pushes risk up).

The dealer-gamma leg’s risk-z is derived from the percentile rank instead of a level z-score: oriented_z = (0.5 − rank) × √12, so a rank of 0 (most-negative gamma) maps to a strongly positive risk-z and a rank of 1 (most-positive gamma) to a strongly negative one. The √12 scaling puts a uniform [0,1] rank on a unit-variance footing comparable to the level z-scores — so the leg’s weight, not an accidental scale mismatch, is the lever.

How the gauge is computed

For each available macro feature with a non-zero baseline standard deviation:

  1. z = (level − trailing_mean) / trailing_std
  2. oriented_z = z × orientation, weight = feature_weights.get(leg, 1.0) (most legs 1.0; funding-stress 1.5; ebp and gz_spread 0.5 each per the double-count split above; t10y3m_spread 0.0 per the wrong-signed / mis-horizoned note above — computed and shown, contributes 0)

For the dealer-gamma leg (when enough history exists):

Then:

  1. mean_z = Σ(oriented_z × weight) / Σ(weight) over available legs
  2. score = 100 / (1 + exp(−mean_z)) — a logistic squash with steepness 1.0, so mean_z = 0 maps to 50, +1σ to ~73, +2σ to ~88, −1σ to ~27.
  3. Band: low under +0.5σ, elevated between +0.5σ and +1.25σ, high at or above +1.25σ.

Each leg’s response carries a contribution field — its signed share of mean_z — so the card shows how much each input moved the gauge. If fewer than four legs are populated the gauge returns status: insufficient_data and the dashboard suppresses the block.

Caveats — read these before you act on it

Where it lives in the code