feat(regime): cut the risk monitor to v4 — desaturate VIX and the trend break

Two sensors saturated in exactly the range where resolution matters, and the top
State band had no headroom. Calibrated with scripts/run_regime_monitor_calibration.py
over the 408 sessions ending 2026-07-24; the shipped code reproduces that run's
band shares exactly (78.9 / 13.0 / 4.7 / 3.4).

V1 read VIX 30, 50 and 82 as an identical 100 — the same defect v3 had just
removed from P3, left in place one sensor over. In the window it flattened five
distinct April-2025 prints (52.33, 46.98, 45.31, 40.72, 38.57) into one value.
Now an anchor table reaching full scale at 55, not at 2020's ~82: anchoring the
top at a once-in-a-generation print would make VIX 50 read only ~70. Pegged on
14 of 408 sessions before; none now.

_under_200 returned a bare 0/100, so P1 printed 100 the moment SMH and QQQ were
both under their average — and since the price pillar takes max(P1, P2, P3),
that pinned the pillar and stopped P3's ladder resolving for the whole of a
selloff. Now graded by depth below the 200-DMA, with a deliberate floor of 20 at
the crossing: the break is a genuine binary event, only its depth is graded.
Pegged on 46 of 408 sessions before; none now. A 2% break reads ~30, not 100.

max() was KEPT — the defect was the step function feeding it, not the vote, and
v3's "one capped vote for correlated reads" rationale still holds. P1 is the sole
price argmax on 17 of 408 sessions (4.2%), so the P1_SCORE_CAP fallback drafted
during design was measured as unnecessary and not shipped.

STATE_BANDS breaking 80 -> 65, and only that threshold. Credit returns 0.0 (not
None) when calm, so it holds its 20 points pinned at zero and price + breadth +
volatility at literal maximum summed to exactly 80.0 — v3's threshold to the
decimal, with nothing above it. The sensor is deliberately unchanged: a
calm-credit selloff genuinely is less stressed. What was stale is the band, fit
on v2 while credit's since-removed percentile leg still contributed. A
2022-style AI/tech drawdown with calm credit computes to 70.3 (no death cross) or
74.0 (with one); 70 would have left 0.33 points of headroom, reproducing the
defect. Chosen by scenario arithmetic, and the realized breaking share then lands
on 3.4% — the same as v3's, arrived at independently.

"v4" added to CATEGORICAL_FUNDAMENTAL_METHODOLOGIES in this same commit, which is
load-bearing: that set is checked against the STORED blob, so bumping without it
discards the collected observation on first write, leaving fetched_at null and
locked false — and update_regime_monitor then fires a paid LLM refresh on every
run, forever. Now guarded by a test parametrised over v2 and v3 stored blobs.

SENSOR_REVISION deliberately stays 2: a METHODOLOGY change already forces a full
reseed via _parse_snapshot, and bumping both would imply the reseed was
revision-driven.

QUADRANT_STATE_DIVIDER stays 50 because only breaking moved, so alert_service,
RegimeChart and the quadrant tests need no change. A new test enforces
divider == band boundary on both axes, which nothing did before.

Doc renamed to regime-monitor-v4.md with a tombstone at the old path (commit
messages cite it), the three open questions converted to resolved with the
reasoning that closed them, and indexed in docs/research/README.md for the first
time. The P2 limit is stated honestly: _death_cross pegs at a -5% MA gap, so a
deep selloff still reaches 100 via P2 — v4 repairs the shallow-to-moderate break,
not "the price pillar no longer pegs".

DEPLOY: the first run reseeds ~464 sessions. Expect one phantom quadrant alert
(the dedup key carries basket_hash, not methodology) and re-run the Event Study
manually — its cached report self-invalidates but does not self-regenerate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 20:34:25 +02:00
co-authored by Claude Opus 5
parent c3ae5ad949
commit 3143477a62
12 changed files with 695 additions and 382 deletions
+72 -14
View File
@@ -1,4 +1,4 @@
"""AI/Tech Risk Monitor v3.
"""AI/Tech Risk Monitor v4.
The monitor is a risk thermometer, not a probability or trading rule. It keeps
two deliberately separate outputs:
@@ -8,13 +8,13 @@ two deliberately separate outputs:
relative strength, credit impulse).
Both scores are quantitative and daily. The sourced hyperscaler capex and
earnings-reaction observations are a qualitative *overlay* in v3 rather than
earnings-reaction observations are a qualitative *overlay* since v3 rather than
weighted sensors: at a combined 20 points they could not reach the event
study's alarm threshold even when both pegged, so refreshing them appeared to
do nothing. They are reported next to the scores instead of inside them.
Daily snapshots are the point-in-time record. The first run under a new
``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once;
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
ordinary runs thereafter only upsert the latest trading date. The overlay is
still gated by its effective date so a rebuild cannot stamp today's observation
onto historical snapshots.
@@ -48,10 +48,14 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
KEY_CONFIG = "regime_monitor_config"
KEY_FUNDAMENTALS = "regime_fundamental_overrides"
METHODOLOGY = "v3"
METHODOLOGY = "v4"
# Snapshots are reseeded on a methodology bump, but fundamental observations are
# collected by hand/LLM and carried across it when the format is compatible.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
# EVERY methodology sharing the categorical format must be listed: this is checked
# against the *stored* blob, so omitting the current one discards the observation
# on its first write, which leaves fetched_at null and locked false -- and then
# update_regime_monitor refreshes it via the LLM on every single run, forever.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4"})
# Bumped when a fix changes what historical rows *should* contain without
# changing the live formula, so stored history needs one reseed. Deliberately
@@ -59,6 +63,10 @@ CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
# study, neither of which is warranted here -- the study recomputes its Warning
# series from source rather than reading snapshots, so a reseed cannot stale it.
# Snapshots written before this marker existed carry no key and read as 1.
# Deliberately NOT bumped for v4: a METHODOLOGY change already forces a full
# reseed (every stored row fails _parse_snapshot, so _latest_snapshot_row returns
# None and rebuilding is True). Bumping both would imply the reseed was
# revision-driven.
SENSOR_REVISION = 2
MIN_COVERAGE = 75.0
SOURCE_MAX_LAG_DAYS = 7
@@ -68,9 +76,18 @@ SOURCE_MAX_LAG_DAYS = 7
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
# numbers chosen so each band covers a sane share of history, not percentile
# fits -- percentile-derived bands would drift on every rebuild and silently
# rewrite what past snapshots meant. Realized shares over the 408 sessions to
# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%.
STATE_BANDS = (20.0, 50.0, 80.0)
# rewrite what past snapshots meant.
#
# v4 moved State's top band 80 -> 65, and only that one. With credit calm it
# scores 0.0 (not None) and still holds its full 20 points, so price + breadth +
# volatility at *literal maximum* summed to exactly 80.0 -- the old threshold, to
# the decimal, with nothing to spare. A 2022-style AI/tech drawdown with calm
# credit computes to 70.3-74.0 depending on whether a death cross has formed, so
# at 80 the case this monitor exists to measure could not print the top band.
# 65 clears it under either assumption. Realized shares over the 408 sessions to
# 2026-07-24, reported not fitted: State 78.9/13.0/4.7/3.4%, Warning 69/20/8/3%.
# The v4 breaking share (3.4%) matches v3's, which was arrived at independently.
STATE_BANDS = (20.0, 50.0, 65.0)
WARNING_BANDS = (20.0, 40.0, 60.0)
QUADRANT_STATE_DIVIDER = 50.0
@@ -118,6 +135,25 @@ P3_DRAWDOWN_ANCHORS = (
(0.0, 0.0), (4.0, 10.0), (8.0, 25.0), (16.0, 50.0), (28.0, 78.0), (40.0, 100.0),
)
# Trend-break depth (% below the 200-DMA, stress score). v4; see _under_200 for
# why the crossing gets a floor of 20 rather than starting at 0. Calibrated to
# sit alongside P3 rather than swamp it -- the 200-DMA lags, so a 20% drawdown
# typically coincides with ~10% below the average, where this reads ~61 against
# P3's ~59. Over the 408 sessions to 2026-07-24 P1 is the sole price argmax on
# 17 of them (4.2%), so it informs the pillar without owning it.
P1_TREND_BREAK_ANCHORS = (
(0.0, 20.0), (3.0, 35.0), (8.0, 55.0), (15.0, 75.0), (25.0, 100.0),
)
# VIX level anchors (v4). Full scale at 55 rather than at 2020's ~82: anchoring
# the top at a once-in-a-generation print would make VIX 50 -- a genuine crisis
# -- read only ~70. A typical correction (25-35) now reads 38-67 where v3 read
# 66.7-100. The anchors encode the long-run distribution as constants, the same
# argument the credit level uses.
P5_VIX_ANCHORS = (
(15.0, 0.0), (20.0, 20.0), (25.0, 38.0), (30.0, 55.0), (40.0, 80.0), (55.0, 100.0),
)
STATE_WEIGHTS = {
"price": 40.0,
"breadth": 25.0,
@@ -219,10 +255,24 @@ def band_for(score: float, bands: tuple[float, float, float] = STATE_BANDS) -> s
def _under_200(closes: list[float]) -> float | None:
"""Trend break graded by depth below the 200-DMA, not a bare yes/no.
Through v3 this returned 0 or 100, so P1 printed 100 the moment SMH and QQQ
were both under their average -- and because the price pillar takes
``max(P1, P2, P3)``, that pinned the pillar and stopped P3's anchored ladder
resolving anything for the whole of a selloff. It pegged on 46 of the 408
sessions to 2026-07-24; under this table, none.
The step at the crossing (0 -> 20) is deliberate: the break itself is a
genuine binary event and deserves a floor. Only the depth past it is graded.
"""
sma200 = _sma(closes, 200)
if sma200 is None:
if sma200 is None or sma200 <= 0:
return None
return 100.0 if closes[-1] < sma200 else 0.0
pct_below = (sma200 - closes[-1]) / sma200 * 100.0
if pct_below <= 0:
return 0.0
return _clamp(_interpolate(pct_below, P1_TREND_BREAK_ANCHORS))
def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
@@ -288,9 +338,17 @@ def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60)
def p5_volatility(vix: float | None) -> float | None:
"""VIX level against named anchors, so it keeps resolving past a 30 print.
v3 used ``(vix - 15) / 15``, which reached 100 at VIX 30 -- the same
saturation v3 itself had just removed from P3. VIX 30 is a bad week, 50 is a
crisis and 82 was March 2020, and all three scored identically. In the 408
sessions to 2026-07-24 that flattened five distinct April-2025 prints
(52.33, 46.98, 45.31, 40.72, 38.57) into a single 100.
"""
if vix is None:
return None
return _clamp((vix - 15.0) / 15.0 * 100.0)
return _clamp(_interpolate(vix, P5_VIX_ANCHORS))
def breadth_level_score(pct_above_200: float | None) -> float | None:
@@ -513,7 +571,7 @@ def _overlay_timing(
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
"""Point-in-time qualitative overlay. Never feeds State or Warning since v3.
The effective-date gate stays even though nothing is scored from this: the
400-session rebuild replays historical dates, and stamping today's LLM read
@@ -597,7 +655,7 @@ def _compute_index(
divergence_series: Series | None = None,
breadth_counts: dict[date, int] | None = None,
) -> dict:
"""Compute the complete v2 State/Warning snapshot as of one trading date."""
"""Compute the complete State/Warning snapshot as of one trading date."""
tickers = config["tickers"]
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
@@ -772,7 +830,7 @@ async def get_regime_config(db: AsyncSession) -> dict:
if stored.get("fundamental_staleness_days") is not None:
cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"])
except (TypeError, ValueError, ValidationError):
logger.warning("Corrupt %s; using v2 defaults", KEY_CONFIG)
logger.warning("Corrupt %s; using defaults", KEY_CONFIG)
return cfg