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
+1 -1
View File
@@ -133,7 +133,7 @@ indicators.
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years. 1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only. 2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v3 risk thermometer; feed no trades. 3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v4 risk thermometer; feed no trades.
4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan. 4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
**Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation: **Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation:
+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 The monitor is a risk thermometer, not a probability or trading rule. It keeps
two deliberately separate outputs: two deliberately separate outputs:
@@ -8,13 +8,13 @@ two deliberately separate outputs:
relative strength, credit impulse). relative strength, credit impulse).
Both scores are quantitative and daily. The sourced hyperscaler capex and 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 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 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. 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 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 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 still gated by its effective date so a rebuild cannot stamp today's observation
onto historical snapshots. onto historical snapshots.
@@ -48,10 +48,14 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
KEY_CONFIG = "regime_monitor_config" KEY_CONFIG = "regime_monitor_config"
KEY_FUNDAMENTALS = "regime_fundamental_overrides" KEY_FUNDAMENTALS = "regime_fundamental_overrides"
METHODOLOGY = "v3" METHODOLOGY = "v4"
# Snapshots are reseeded on a methodology bump, but fundamental observations are # Snapshots are reseeded on a methodology bump, but fundamental observations are
# collected by hand/LLM and carried across it when the format is compatible. # 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 # Bumped when a fix changes what historical rows *should* contain without
# changing the live formula, so stored history needs one reseed. Deliberately # 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 # 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. # 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. # 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 SENSOR_REVISION = 2
MIN_COVERAGE = 75.0 MIN_COVERAGE = 75.0
SOURCE_MAX_LAG_DAYS = 7 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 # 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 # numbers chosen so each band covers a sane share of history, not percentile
# fits -- percentile-derived bands would drift on every rebuild and silently # fits -- percentile-derived bands would drift on every rebuild and silently
# rewrite what past snapshots meant. Realized shares over the 408 sessions to # rewrite what past snapshots meant.
# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%. #
STATE_BANDS = (20.0, 50.0, 80.0) # 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) WARNING_BANDS = (20.0, 40.0, 60.0)
QUADRANT_STATE_DIVIDER = 50.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), (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 = { STATE_WEIGHTS = {
"price": 40.0, "price": 40.0,
"breadth": 25.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: 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) sma200 = _sma(closes, 200)
if sma200 is None: if sma200 is None or sma200 <= 0:
return None 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: 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: 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: if vix is None:
return 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: 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: 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 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 400-session rebuild replays historical dates, and stamping today's LLM read
@@ -597,7 +655,7 @@ def _compute_index(
divergence_series: Series | None = None, divergence_series: Series | None = None,
breadth_counts: dict[date, int] | None = None, breadth_counts: dict[date, int] | None = None,
) -> dict: ) -> 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"] tickers = config["tickers"]
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of) smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
qqq = _closes_asof(prices.get(tickers["confirm"][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: if stored.get("fundamental_staleness_days") is not None:
cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"]) cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"])
except (TypeError, ValueError, ValidationError): except (TypeError, ValueError, ValidationError):
logger.warning("Corrupt %s; using v2 defaults", KEY_CONFIG) logger.warning("Corrupt %s; using defaults", KEY_CONFIG)
return cfg return cfg
+12
View File
@@ -214,3 +214,15 @@ it. See the [frozen specification](portfolio-capacity-bracket.md) and the
[capacity findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens). [capacity findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
The next real evidence is **forward**, not backward: the live paper-trade record. The next real evidence is **forward**, not backward: the live paper-trade record.
## AI/Tech Risk Monitor
An observational risk thermometer (State + Warning) shown on the Risk page. It
gates nothing — no entries, exits, sizing or ranking — so it is not a strategy
document, but its calibration follows the same rules as one.
- [Methodology, v4](regime-monitor-v4.md) — sensors, weights, bands, and the
reasoning behind each cut from v2 onward.
- Reproduce any number in it with `scripts/run_regime_monitor_calibration.py`,
which replays the series offline and refuses to report unless it first
reproduces the published v2 and v3 figures.
+4 -320
View File
@@ -1,322 +1,6 @@
# AI/Tech Risk Monitor v3 methodology # Moved
Named "Regime Monitor" until 2026-08-07; the filename, the `regime_monitor` job The methodology doc now lives at [regime-monitor-v4.md](regime-monitor-v4.md).
id, the `/regime` route and the `METHODOLOGY`/snapshot fields keep the old word,
because those are persisted or externally linked. Only the wording changed.
The AI/Tech Risk Monitor is an observational risk thermometer. It does not v3's text is in git history (`git log --follow docs/research/regime-monitor-v4.md`).
gate entries, exits, position size, ranking, or alerts about individual setups. This stub exists because commit messages up to 2026-08-08 cite the old path.
v3 supersedes v2. Every parameter below was calibrated against the 408 v2
sessions ending 2026-07-24, reproduced offline from the same Alpaca and FRED
inputs the live job uses; the reproduction matched the stored prod distribution
exactly (State avg 22.6/22.7, p80 35.1, max 91.2, P3 pegged 39, W1 live 108).
## What changed and why
**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down)
carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a
Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and
still inside the "stable" band. The sourced observation could not change any
published conclusion, so refreshing it looked like it did nothing. They are now
a qualitative overlay reported beside the scores. Capex also stopped scoring
`raising` and `holding` identically at 0: `holding` is the deceleration case and
now scores 50, so a boom no longer reads the same as a stall.
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
sessions sat at exactly 100 with no resolution left, and the price pillar showed
the top band on 13.5% of sessions. v3 uses named anchors with headroom past the
observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did
instead of taking `max()`. P3's realized share of State falls from 65% to 40%,
matching its nominal weight.
**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero
below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing
in a calm tape. Its 20-session rate of change still does, and spread widening is
a classic lead.
**The credit percentile leg was removed.** Its reference window silently shrank
from 10 years to 3 when ICE restricted the upstream series in April 2026, after
which it scored 20 points of stress at a spread the same sensor's anchors call
"mild". See Calibration below.
**Breadth loss counts during declines.** v2's divergence gate was
`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the
basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and
Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts
fully when price masks it (true divergence, the dangerous pre-top case) and at
35% when price confirms it. Breadth *level* lives in State, but breadth
*velocity* appears nowhere else, so this is not double counting.
**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while
State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The
upper half of the Warning axis was unreachable.
## Outputs
**State** — current structural stress:
- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads.
- Fixed-basket breadth level, 25%.
- HY option-adjusted credit spread level, 20%.
- VIX level, 15%.
**Warning** — deterioration and divergence:
- Fixed-basket breadth divergence, 45%.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- HY OAS 20-session widening, 25%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3.
## Calibration
P3 drawdown anchors, as (drawdown %, score): 0→0, 4→10, 8→25, 16→50, 28→78,
40→100, flat outside. Credit impulse is relative (+35% over 20 sessions = 100)
rather than absolute, because +0.5pp means something very different at an OAS of
2.7 than at 8.0.
Bands are round, meaning-anchored numbers, not percentile fits — percentile
thresholds would drift on every rebuild and silently rewrite what past snapshots
meant. Realized shares over the calibration window:
| Axis | stable | watch | elevated | breaking | thresholds |
|------|--------|-------|----------|----------|------------|
| State | 73.3% | 15.0% | 8.3% | 3.4% | 20 / 50 / 80 |
| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 |
Quadrant dividers sit at each axis's watch/elevated boundary: State 50,
Warning 40.
Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating
pillar set changes. Zero means ordinary/healthy; only stress contributes.
Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0
stressed, linear between, and nothing else. v2 blended those anchors at 70% with
a 30% upper-tail percentile over a nominally 10-year window.
That leg was removed rather than repaired. ICE restricted FRED to a rolling
3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it
outright ("Starting in April 2026, this series will only include 3 years of
observations"), and an unbounded request returns the same 795 observations as a
30-year one. The v2 percentile therefore ranked the current spread against three
uniformly tight years (range 2.594.61 over the calibration window), which made
it fire early and saturate absurdly: at an OAS of 3.50 — the level the anchors
call *mild*, scoring zero stress — the blended sensor read 20.1, and the
percentile leg pegged at 100 by an OAS of 4.5. Across the 408 sessions it
roughly tripled the credit sensor's average (2.70 vs 1.00) and more than doubled
its nonzero days (60 vs 27).
The anchors already encode the long-run distribution as constants, so the
percentile was a second, noisier estimate of the same thing. What it was
genuinely reaching for — "unusual versus recent history" — is now W3 on the
Warning axis, computed as a rate of change, which is where deterioration
belongs. Removing it moved State's average by 0.4 and its maximum by 3.8, left
Warning bit-identical, and did not shift any band threshold.
A long-history alternative (`BAA10Y`, Fed-published, 7,712 observations back to
1997) was considered and rejected: ranking an HY spread against investment-grade
history is not a coherent statistic, and it would rescue a leg that is redundant
anyway.
Every snapshot now records `data_quality.credit_history_days` and
`vix_history_days`. This defect was invisible for roughly three months because
nothing asserted the window the code claimed; the spans make a future upstream
truncation show up in the record instead of quietly reshaping a sensor.
**Survivorship caveat.** The basket was frozen 2026-07-15 but the calibration
window reaches back to 2024, so names were partly selected for having done well.
Every distribution above inherits that bias. It is the same bias v2 carried, so
the v2/v3 comparison is like-for-like, but the absolute band shares are
optimistic.
## Point-in-time record
The first run under a new `METHODOLOGY` rebuilds the latest 400 trading sessions
with sufficient sensor warm-up; routine runs thereafter insert/update only the
latest trading date. The history API and main chart show only snapshots matching
the current methodology, so a bump reseeds the series rather than splicing two
formulas into one line.
The fundamental overlay keeps its effective date (normally the next session after
collection) and is never replayed backward, so a rebuild cannot stamp today's
observation onto historical snapshots. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot
therefore reports the overlay as `pending` until the new effective date.
Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
the gate — it runs for every replayed date during a rebuild, so it must never
grow a bypass flag. `current_observation` is the **live reading** behind
`fundamental_context`, and *reports* the effective date instead of blanking the
content.
Until 2026-08-07 the live reading called the gated function, so a just-collected
observation stayed hidden until the next weekday — three days over a weekend —
and refreshing appeared to do nothing. That was the opposite of what this section
already claimed. Showing it early cannot leak into a published number, because
nothing in the overlay is scored (see "Fundamentals left the score").
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
every path writing real content stamps). Without it, the default override —
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
live observation with `available: true`, so the card presented placeholders as a
collected reading. Those are the absence of an observation, not an observation of
absence. `fundamental_overlay` never had this problem: no observation means no
effective date, which means `pending`, which already blanks the content.
Each snapshot stores the fixed basket symbols, hash, and freeze date.
Reconstructed history before that freeze date is retrospective/exploratory.
## Presentation
The page is deliberately thin: two gauges, one chart card, one pillar table, the
overlay, and a provenance strip. Time and Path are two projections of the same
snapshot series and share one card and one query key — they were previously two
panels, which read as two datasets. Methodology rationale lives in this document,
not on the page; page text is limited to what changes how the reader interprets
today's number. The quadrant dividers rendered in Path view come from
`quadrant_config` and are the same constants the alert path consumes
(`alert_service`), so the chart cannot drift from what actually fires.
## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
measured on the final 30%. Because v3 dropped fundamentals from the score, the
study now measures exactly the live Warning score rather than a technical-only
approximation of it, and both are computed from one shared sensor definition
(`warning_sensor_scores`) so they cannot drift apart.
A cached report is discarded when its methodology no longer matches, so the panel
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
the Event Study job after cutting over to v3.**
### Reading the result
The report carries a `reliability` block and the UI renders its warnings, because
the headline numbers invite over-reading in two specific ways.
**The holdout is thin.** The study detects 11 corrections across 5 years but the
70/30 split leaves only 4 in the test period. Recall is therefore one event away
from a materially different headline, and in practice the event that flips is
decided by where the frozen threshold happens to land rather than by whether the
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
rising edge, and a lower threshold can mean the alarm already fired outside the
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
holdout events the report says so explicitly.
Some events carry no information at all for comparison: in that run every
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
warning.
**Sensor coverage can straddle the split.** The score renormalises over available
sensors, so a training window predating a sensor's history freezes the threshold
on a different construct than the holdout is measured against. At the v3 cutover
only 39% of training sessions had all three Warning sensors versus 100% of the
test period, because credit history begins 2023-07-25.
Restricting the threshold to sensor-matched training sessions was tried and is
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
coverage bias for a regime-selection bias. The honest position is that the
threshold is hypersensitive to window choice at this sample size; the report
states its limits rather than pretending to a precision it does not have.
## Open calibration questions
Raised 2026-08-07 during the page refactor. **None are implemented.** Each one
changes a published score, so acting on any of them means cutting `METHODOLOGY`
to v4 — which reseeds 400 sessions and discards the cached event study. They are
recorded here rather than hand-patched into v3.
**1. State's top band is a credit-event band.** `f2_credit_spreads` returns
`0.0` — not `None` — for any OAS below the 3.5 mild anchor, so credit stays
*available* at weight 20 and is not renormalized out. It is simply pinned at
zero. Verified: with price, breadth and volatility all pegged at 100 and OAS at
the cutover's 2.77, State computes to exactly **80.0** at 100% coverage — the
"breaking" threshold to the decimal. So the top State band requires either a
credit event or all three remaining pillars simultaneously at maximum. A pure
AI/Tech drawdown with calm credit — the scenario this monitor exists to
measure — cannot print it with anything to spare. Anchors-only credit was
nonzero on 27 of 408 calibration sessions, so that 20-point weight sits at zero
roughly 93% of the time. This is structurally the same defect v3 corrected on
the Warning axis ("the upper half of the Warning axis was unreachable"), and it
means the State bands were fit against a v2 credit distribution that v3 no
longer produces.
**2. V1 saturates at VIX 30.** `(vix - 15) / 15 * 100` reaches 100 at VIX 30 and
has no resolution above it: VIX 30, 50 and 82 all score identically. That is the
same failure mode, at a similar percentile, as the `dd_pct * 5` formula this
version replaced for pegging at a 20% drawdown. If addressed, it should get an
anchor table in the P3 style rather than a rescaled slope.
**3. `max(P1, P2, P3)` defeats P3's anchoring.** The `max` is deliberate ("one
capped vote for correlated reads"), but `_under_200` is binary, so P1 prints 100
whenever SMH and QQQ are both below their 200-DMA. P3's anchor ladder therefore
only resolves anything while price is *above* the 200-DMA — that is, before the
drawdown it measures is underway. Note also that "P3's realized share of State
falls from 65% to 40%" is argmax-share accounting, which is a slippery statistic
under `max()`.
## Fixed 2026-08-07: the OAS fetch window did not cover a rebuild
`HY_OAS_WINDOW_DAYS` was 400 **calendar** days, but a rebuild replays
`leader_series[-REBUILD_SESSIONS:]` — 400 **trading** sessions, about 579
calendar days. The oldest ~180 calendar days of any rebuild therefore got no OAS
data at all, so `f2_credit_spreads` and `w3_credit_impulse` both returned `None`.
Verified: State then lands at 80% coverage and Warning at exactly 75.0% —
`MIN_COVERAGE` — so **both still publish bands**. The rebuilt series would look
homogeneous while its oldest rows had been scored without credit, the tell being
a null `data_quality.credit_history_days` on exactly those rows.
The window is now 700 days: it must cover the oldest replayed date (~579) plus
W3's lookback and slack, while staying under ICE's ~3-year cap so FRED still
honours the request. This required **no methodology bump** — C1 reads
`oas_values[-1]` and W3 reads `oas_values[-21]`, both indexed from the end, so
widening only prepends older observations and every live score is bit-identical.
Confirmed by evaluating both windows against a varying synthetic series: today's
C1/W3 match exactly, while the oldest rebuild row goes from `None`/`None` to real
values.
Expect `credit_history_days` on new snapshots to rise from ~400 to ~700. That is
the widened request, not new upstream history — and it makes the chip a better
truncation canary, since a 700-day request returning ~1095 days' worth is now
the visible ceiling.
**Widening the window alone does not repair stored history.** Routine runs
recompute only the latest trading date, and `rebuilding` was keyed on "no v3
snapshot exists at all" — which is false once the cutover has run — so every row
already written would have kept its credit gap indefinitely. `SENSOR_REVISION`
fixes that: it is stamped into each snapshot, snapshots predating it read as 1,
and a stored revision below the current one triggers exactly one reseed.
It is deliberately not `METHODOLOGY`. That constant partitions the history API
and discards the cached event study; neither is warranted here, because the study
recomputes its Warning series from source (`_warning_series` calls
`warning_sensor_scores` against freshly fetched prices and OAS) rather than
reading snapshots, so a reseed cannot stale it.
The reseed is bounded by `REBUILD_LOOKBACK_DAYS` in calendar days rather than a
session count, because the binding constraint is the OAS fetch: each replayed row
needs W3's 20-business-day lookback inside `HY_OAS_WINDOW_DAYS`. At 672 days the
replay reaches ~464 sessions, W3's oldest requirement lands exactly on the first
fetched OAS day, and the ~400-session series the v3 cutover wrote is fully
covered. A test asserts that relationship so the two constants cannot drift into
recreating the gap.
The fix was sequenced deliberately: acting on items 13 above bumps
`METHODOLOGY`, which fires `rebuilding`, which would have baked the credit-less
rows into the fresh series. Fixing the window afterwards would mean reseeding
twice.
## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they
require fresh inputs, at least 75% coverage on both axes, two consecutive daily
confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer —
not a trade signal.**
+439
View File
@@ -0,0 +1,439 @@
# AI/Tech Risk Monitor v4 methodology
Named "Regime Monitor" until 2026-08-07; the filename's `regime` stem, the
`regime_monitor` job id, the `/regime` route and the `METHODOLOGY`/snapshot
fields keep the old word, because those are persisted or externally linked.
The AI/Tech Risk Monitor is an observational risk thermometer. It does not
gate entries, exits, position size, ranking, or alerts about individual setups.
**v4 supersedes v3** (2026-08-08). Unlike v3, whose calibration was ad-hoc and
never landed, every number below is reproducible:
.\.venv\Scripts\python.exe scripts
un_regime_monitor_calibration.py ^
--methodology v2_reconstruction,v3,v4 --cache-dir .calib-cache
The harness replays the 408 sessions ending 2026-07-24 from the live inputs
(Alpaca for all 33 symbols, FRED for VIX and HY OAS) with no database, and
reproduces the published v2 and v3 figures before it will emit anything:
| figure | published | replayed |
|---|---|---|
| v2 State avg | 22.6 | 22.68 |
| v2 State p80 | 35.1 | **35.1** |
| v2 State max | 91.2 | **91.2** |
| v2 P3 pegged | 39 | **39** |
| v2 W1 live | 108 | **108** |
| v3 State max | 87.4 | **87.4** |
| v3 band shares | 73.3 / 15.0 / 8.3 / 3.4 | 73.0 / 15.4 / 8.1 / 3.4 |
It refuses to emit a band recommendation, and exits non-zero, unless every hard
gate passes — 33 symbols fetched with full warm-up, the whole basket on every
session, the calendar anchors, 100% coverage on every row, and a row-wise
`state_v4 <= state_v3` invariant. Reading a calibration result out of a run whose
pipeline did not validate is meant to be structurally impossible.
## What changed in v4
**V1 stopped saturating at VIX 30.** `(vix - 15) / 15` reached 100 at VIX 30 —
the same defect v3 had *just* removed from P3, left in place one sensor over. VIX
30 is a bad week, 50 is a crisis and 82 was March 2020, and all three scored
identically. In the calibration window this flattened five distinct April-2025
prints (52.33, 46.98, 45.31, 40.72, 38.57) into a single 100. It pegged on 14 of
408 sessions; under the anchors below, none.
**The trend break is graded by depth, not a yes/no.** `_under_200` returned a
bare 0/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 408 sessions; now none. A 2% break reads ~30 where it
used to read 100.
`max()` was **kept**. The defect was the step function feeding it, not the vote
itself, and v3's "one capped vote for correlated reads" rationale still holds.
Over the window P1 is the sole price argmax on 17 of 408 sessions (4.2%), so it
informs the pillar without owning it — the `P1_SCORE_CAP` fallback considered
during design was measured as unnecessary and not shipped.
**The top State band moved 80 → 65.** See Calibration; this is the one change
that is about the band rather than a sensor.
**Scope.** All three are State-side. `WARNING_BANDS`, `WARNING_WEIGHTS`,
`QUADRANT_WARNING_DIVIDER` and the event study's frozen threshold are untouched.
`QUADRANT_STATE_DIVIDER` stays 50 because only `breaking` moved.
## What changed in v3
**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down)
carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a
Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and
still inside the "stable" band. The sourced observation could not change any
published conclusion, so refreshing it looked like it did nothing. They are now
a qualitative overlay reported beside the scores. Capex also stopped scoring
`raising` and `holding` identically at 0: `holding` is the deceleration case and
now scores 50, so a boom no longer reads the same as a stall.
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
sessions sat at exactly 100 with no resolution left, and the price pillar showed
the top band on 13.5% of sessions. v3 uses named anchors with headroom past the
observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did
instead of taking `max()`. P3's realized share of State falls from 65% to 40%,
matching its nominal weight.
**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero
below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing
in a calm tape. Its 20-session rate of change still does, and spread widening is
a classic lead.
**The credit percentile leg was removed.** Its reference window silently shrank
from 10 years to 3 when ICE restricted the upstream series in April 2026, after
which it scored 20 points of stress at a spread the same sensor's anchors call
"mild". See Calibration below.
**Breadth loss counts during declines.** v2's divergence gate was
`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the
basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and
Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts
fully when price masks it (true divergence, the dangerous pre-top case) and at
35% when price confirms it. Breadth *level* lives in State, but breadth
*velocity* appears nowhere else, so this is not double counting.
**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while
State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The
upper half of the Warning axis was unreachable.
## Outputs
**State** — current structural stress:
- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads.
- Fixed-basket breadth level, 25%.
- HY option-adjusted credit spread level, 20%.
- VIX level, 15%.
**Warning** — deterioration and divergence:
- Fixed-basket breadth divergence, 45%.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- HY OAS 20-session widening, 25%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3 or v4.
## Calibration
### Interpolated sensor tables
All three are `(x, stress score)` pairs read by `_interpolate`, flat outside the
first and last anchor.
| sensor | anchors |
|---|---|
| P3 drawdown (% below the 52w high) | 0→0, 4→10, 8→25, 16→50, 28→78, 40→100 |
| **P1 trend break** (% below the 200-DMA) | 0→**20**, 3→35, 8→55, 15→75, 25→100 |
| **V1 volatility** (VIX level) | 15→0, 20→20, 25→38, 30→55, 40→80, 55→100 |
P1's floor of 20 at the crossing is deliberate: the break itself is a genuine
binary event and deserves a floor; only the depth past it is graded. P1 is
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 P1 reads ~61
against P3's ~59.
V1 reaches 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.
The anchors encode the long-run distribution as constants, the same argument the
credit level uses. Unlike P3 and V1, whose slopes ease off monotonically, P3's do
not (2.5, 3.75, 3.125, 2.33, 1.83) — its gentle onset is intentional and the
monotone-slope test excludes it.
Credit impulse is relative (+35% over 20 sessions = 100) rather than absolute,
because +0.5pp means something very different at an OAS of 2.7 than at 8.0.
### Bands
Round, meaning-anchored numbers, **not** percentile fits — those would drift on
every rebuild and silently rewrite what past snapshots meant.
**Why `breaking` moved 80 → 65.** With credit calm, `f2_credit_spreads` returns
`0.0` (not `None`), so it keeps its full 20 points pinned at zero. Price, breadth
and volatility at *literal maximum* therefore sum to:
(100×40 + 100×25 + 0×20 + 100×15) / 100 = 80.0 exactly
`band_for` uses `>=`, so v3's top band was reachable only by touching its floor
to the decimal, with nothing above it. The band was fit on v2, when credit's
since-removed percentile leg still contributed regularly; the sensor is not
wrong — a calm-credit selloff genuinely *is* less stressed than one with credit
contagion — the threshold was stale.
Chosen by scenario arithmetic on unchanged weights (`_scenarios` in the harness
computes these, so they are machine-checked, not prose):
| scenario | price | breadth | C1 | V1 | State |
|---|---|---|---|---|---|
| Ordinary tape (3% dd, breadth 65%, VIX 16, OAS 2.8) | 7.5 | 0 | 0 | 4.0 | **3.6** |
| 10% correction, calm credit (2% below, breadth 35%, VIX 24) | 31.2 | 62.5 | 0 | 34.4 | **33.3** |
| **2022-style drawdown, calm credit, no death cross** | 90.8 | 100 | 0 | 60.0 | **70.3** |
| **same, with death cross** (P2 pegged) | 100 | 100 | 0 | 60.0 | **74.0** |
| Credit event on top (OAS 6.0, VIX 45) | 100 | 100 | 75.0 | 86.7 | **93.0** |
| March 2020 (everything pegged) | 100 | 100 | 100 | 100 | **100** |
Rows 3 and 4 are the case this monitor exists to measure, and they must print
`breaking`. At 80 they do not. **65** clears them under either P2 assumption,
which matters because P2 is set by the 50/200-DMA gap and no drawdown figure
implies it; 70 would have left 0.33 points of headroom in row 3, reproducing the
defect being fixed.
Realized shares, **reported not fitted**, over the 408 sessions to 2026-07-24:
| Axis | stable | watch | elevated | breaking | thresholds |
|------|--------|-------|----------|----------|------------|
| State (v4) | 78.9% | 13.0% | 4.7% | **3.4%** | 20 / 50 / **65** |
| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 |
The v4 `breaking` share lands on 3.4% — the same as v3's — having been chosen by
scenario reasoning rather than aimed at that number. Sensitivity: 60 gives 5.1%,
70 gives 1.2%.
Quadrant dividers sit at each axis's watch/elevated boundary: State 50,
Warning 40. Only `breaking` moved in v4, so the dividers and every alert
threshold are unchanged. `test_quadrant_dividers_match_the_band_boundaries` now
enforces that relationship, which nothing did before.
Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating
pillar set changes. Zero means ordinary/healthy; only stress contributes.
Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0
stressed, linear between, and nothing else. v2 blended those anchors at 70% with
a 30% upper-tail percentile over a nominally 10-year window.
That leg was removed rather than repaired. ICE restricted FRED to a rolling
3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it
outright ("Starting in April 2026, this series will only include 3 years of
observations"), and an unbounded request returns the same 795 observations as a
30-year one. The v2 percentile therefore ranked the current spread against three
uniformly tight years (range 2.594.61 over the calibration window), which made
it fire early and saturate absurdly: at an OAS of 3.50 — the level the anchors
call *mild*, scoring zero stress — the blended sensor read 20.1, and the
percentile leg pegged at 100 by an OAS of 4.5. Across the 408 sessions it
roughly tripled the credit sensor's average (2.70 vs 1.00) and more than doubled
its nonzero days (60 vs 27).
The anchors already encode the long-run distribution as constants, so the
percentile was a second, noisier estimate of the same thing. What it was
genuinely reaching for — "unusual versus recent history" — is now W3 on the
Warning axis, computed as a rate of change, which is where deterioration
belongs. Removing it moved State's average by 0.4 and its maximum by 3.8, left
Warning bit-identical, and did not shift any band threshold.
A long-history alternative (`BAA10Y`, Fed-published, 7,712 observations back to
1997) was considered and rejected: ranking an HY spread against investment-grade
history is not a coherent statistic, and it would rescue a leg that is redundant
anyway.
Every snapshot now records `data_quality.credit_history_days` and
`vix_history_days`. This defect was invisible for roughly three months because
nothing asserted the window the code claimed; the spans make a future upstream
truncation show up in the record instead of quietly reshaping a sensor.
**Survivorship caveat.** The basket was frozen 2026-07-15 but the calibration
window reaches back to 2024, so names were partly selected for having done well.
Every distribution above inherits that bias. It is the same bias v2 carried, so
the v2/v3 comparison is like-for-like, but the absolute band shares are
optimistic.
## Point-in-time record
The first run under a new `METHODOLOGY` rebuilds the latest 400 trading sessions
with sufficient sensor warm-up; routine runs thereafter insert/update only the
latest trading date. The history API and main chart show only snapshots matching
the current methodology, so a bump reseeds the series rather than splicing two
formulas into one line.
The fundamental overlay keeps its effective date (normally the next session after
collection) and is never replayed backward, so a rebuild cannot stamp today's
observation onto historical snapshots. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot
therefore reports the overlay as `pending` until the new effective date.
Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
the gate — it runs for every replayed date during a rebuild, so it must never
grow a bypass flag. `current_observation` is the **live reading** behind
`fundamental_context`, and *reports* the effective date instead of blanking the
content.
Until 2026-08-07 the live reading called the gated function, so a just-collected
observation stayed hidden until the next weekday — three days over a weekend —
and refreshing appeared to do nothing. That was the opposite of what this section
already claimed. Showing it early cannot leak into a published number, because
nothing in the overlay is scored (see "Fundamentals left the score").
`current_observation` gates on `observed` (a non-null `fetched_at`, the one field
every path writing real content stamps). Without it, the default override —
`unknown` for every hyperscaler and `mixed` for the reaction — was reported as a
live observation with `available: true`, so the card presented placeholders as a
collected reading. Those are the absence of an observation, not an observation of
absence. `fundamental_overlay` never had this problem: no observation means no
effective date, which means `pending`, which already blanks the content.
Each snapshot stores the fixed basket symbols, hash, and freeze date.
Reconstructed history before that freeze date is retrospective/exploratory.
## Presentation
The page is deliberately thin: two gauges, one chart card, one pillar table, the
overlay, and a provenance strip. Time and Path are two projections of the same
snapshot series and share one card and one query key — they were previously two
panels, which read as two datasets. Methodology rationale lives in this document,
not on the page; page text is limited to what changes how the reader interprets
today's number. The quadrant dividers rendered in Path view come from
`quadrant_config` and are the same constants the alert path consumes
(`alert_service`), so the chart cannot drift from what actually fires.
## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first
70% of sessions freezes the 80th-percentile warning threshold; alarm episodes are
measured on the final 30%. Because v3 dropped fundamentals from the score, the
study now measures exactly the live Warning score rather than a technical-only
approximation of it, and both are computed from one shared sensor definition
(`warning_sensor_scores`) so they cannot drift apart.
A cached report is discarded when its methodology no longer matches, so the panel
reverts to "not run yet" after a bump rather than showing stale numbers. **Re-run
the Event Study job after cutting over to v4.**
### Reading the result
The report carries a `reliability` block and the UI renders its warnings, because
the headline numbers invite over-reading in two specific ways.
**The holdout is thin.** The study detects 11 corrections across 5 years but the
70/30 split leaves only 4 in the test period. Recall is therefore one event away
from a materially different headline, and in practice the event that flips is
decided by where the frozen threshold happens to land rather than by whether the
score saw anything. The v3 cutover run illustrates it: v3 scored 2/4 against v2's
3/4, but "v3 without the credit sensor" scores 3/4 at a *higher* threshold
(35.5) than shipped v3 misses it at (32.3) — because the alarm rule needs a
rising edge, and a lower threshold can mean the alarm already fired outside the
20-session horizon and never reset below. Below `MIN_EVENTS_FOR_CONFIDENCE`
holdout events the report says so explicitly.
Some events carry no information at all for comparison: in that run every
variant caught 2026-03-06, every variant missed 2026-06-05, and every variant
"caught" 2025-11-20 with a 1-session lead, which is coincident rather than a
warning.
**Sensor coverage can straddle the split.** The score renormalises over available
sensors, so a training window predating a sensor's history freezes the threshold
on a different construct than the holdout is measured against. At the v3 cutover
only 39% of training sessions had all three Warning sensors versus 100% of the
test period, because credit history begins 2023-07-25.
Restricting the threshold to sensor-matched training sessions was tried and is
*not* the fix: those sessions are a calm recent stretch, so the threshold drops
from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6 per year. It trades a
coverage bias for a regime-selection bias. The honest position is that the
threshold is hypersensitive to window choice at this sample size; the report
states its limits rather than pretending to a precision it does not have.
## Resolved in v4 (raised 2026-08-07, shipped 2026-08-08)
The three questions this section used to hold are now answered. Kept here
because the reasoning that resolved them is not obvious from the code.
**1. `breaking` had zero headroom — resolved by moving the band, not the sensor.**
`f2_credit_spreads` returns `0.0`, not `None`, below the 3.5 mild anchor, so
credit stays *available* at weight 20 and is pinned at zero on roughly 93% of
sessions rather than being renormalized out. Price + breadth + volatility at
literal maximum therefore summed to exactly 80.0 — v3's threshold, to the
decimal.
The sensor is **deliberately unchanged**. A calm-credit selloff genuinely is less
stressed than one with credit contagion, so scoring it lower is correct; what was
stale was `STATE_BANDS`, fit on v2 while credit's since-removed percentile leg
still contributed. Making credit `None` when calm was considered and rejected: it
would leave State on 80% coverage, which still publishes, but consumes the whole
buffer — any *second* missing pillar would then suppress the band, and the 7d/30d
trend deltas would null out every time OAS crossed 3.5, because `_delta`
suppresses on a change of participating pillars. See Calibration for the
scenario arithmetic behind 65.
**2. V1 saturated at VIX 30 — resolved with an anchor table.** See "What changed
in v4".
**3. `max(P1, P2, P3)` defeated P3's anchoring — resolved by grading `_under_200`,
keeping `max()`.** The `max` was deliberate ("one capped vote for correlated
reads") and survives; the binary step feeding it was the defect.
**Its limit, stated precisely.** `_death_cross` is `clamp(-gap_pct * 20)`, so P2
pegs at a 5% 50/200-DMA gap — routine in a real downtrend. In a *deep* selloff
the price pillar therefore still reaches 100 via P2 even with P1 graded. What v4
repairs is the shallow-to-moderate break, which is where resolution was most
obviously missing: a 10% correction 2% below the average now scores 31 where v3
scored 100. It would be wrong to claim "the price pillar no longer pegs".
P2 did not peg once in the 408-session calibration window, so this is a property
of the sensor rather than an observed problem. Grading P2 the same way is the
natural next item if it starts binding; the replay reports a P2-pegged census
alongside P3 and V1 so the evidence accumulates.
## Fixed 2026-08-07: the OAS fetch window did not cover a rebuild
`HY_OAS_WINDOW_DAYS` was 400 **calendar** days, but a rebuild replays
`leader_series[-REBUILD_SESSIONS:]` — 400 **trading** sessions, about 579
calendar days. The oldest ~180 calendar days of any rebuild therefore got no OAS
data at all, so `f2_credit_spreads` and `w3_credit_impulse` both returned `None`.
Verified: State then lands at 80% coverage and Warning at exactly 75.0% —
`MIN_COVERAGE` — so **both still publish bands**. The rebuilt series would look
homogeneous while its oldest rows had been scored without credit, the tell being
a null `data_quality.credit_history_days` on exactly those rows.
The window is now 700 days: it must cover the oldest replayed date (~579) plus
W3's lookback and slack, while staying under ICE's ~3-year cap so FRED still
honours the request. This required **no methodology bump** — C1 reads
`oas_values[-1]` and W3 reads `oas_values[-21]`, both indexed from the end, so
widening only prepends older observations and every live score is bit-identical.
Confirmed by evaluating both windows against a varying synthetic series: today's
C1/W3 match exactly, while the oldest rebuild row goes from `None`/`None` to real
values.
Expect `credit_history_days` on new snapshots to rise from ~400 to ~700. That is
the widened request, not new upstream history — and it makes the chip a better
truncation canary, since a 700-day request returning ~1095 days' worth is now
the visible ceiling.
**Widening the window alone does not repair stored history.** Routine runs
recompute only the latest trading date, and `rebuilding` was keyed on "no v3
snapshot exists at all" — which is false once the cutover has run — so every row
already written would have kept its credit gap indefinitely. `SENSOR_REVISION`
fixes that: it is stamped into each snapshot, snapshots predating it read as 1,
and a stored revision below the current one triggers exactly one reseed.
It is deliberately not `METHODOLOGY`. That constant partitions the history API
and discards the cached event study; neither is warranted here, because the study
recomputes its Warning series from source (`_warning_series` calls
`warning_sensor_scores` against freshly fetched prices and OAS) rather than
reading snapshots, so a reseed cannot stale it.
The reseed is bounded by `REBUILD_LOOKBACK_DAYS` in calendar days rather than a
session count, because the binding constraint is the OAS fetch: each replayed row
needs W3's 20-business-day lookback inside `HY_OAS_WINDOW_DAYS`. At 672 days the
replay reaches ~464 sessions, W3's oldest requirement lands exactly on the first
fetched OAS day, and the ~400-session series the v3 cutover wrote is fully
covered. A test asserts that relationship so the two constants cannot drift into
recreating the gap.
The fix was sequenced deliberately: acting on items 13 above bumped
`METHODOLOGY`, which fires `rebuilding`, which would have baked the credit-less
rows into the fresh series. Fixing the window first meant the v4 reseed replayed
a clean window; doing it the other way round would have meant reseeding twice.
## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they
require fresh inputs, at least 75% coverage on both axes, two consecutive daily
confirmations, hysteresis, and cooldown. Every alert states: **Risk thermometer —
not a trade signal.**
@@ -41,7 +41,7 @@ const PATH_TRAIL = 60;
const STATE_COLOR = '#60a5fa'; const STATE_COLOR = '#60a5fa';
const WARNING_COLOR = '#fb923c'; const WARNING_COLOR = '#fb923c';
// Fall back to the v3 constants, not v2's shared 60/60, so a missing // Fall back to the shipped constants, not v2's shared 60/60, so a missing
// quadrant_config cannot draw dividers that disagree with the alert path. // quadrant_config cannot draw dividers that disagree with the alert path.
const DEFAULT_STATE_DIVIDER = 50; const DEFAULT_STATE_DIVIDER = 50;
const DEFAULT_WARNING_DIVIDER = 40; const DEFAULT_WARNING_DIVIDER = 40;
+1 -1
View File
@@ -562,7 +562,7 @@ export interface RegimeMonitor {
} }
export interface RegimeFundamentals { export interface RegimeFundamentals {
methodology: 'v3'; methodology: 'v4';
f1_score: number | null; f1_score: number | null;
f3_score: number | null; f3_score: number | null;
locked: boolean; locked: boolean;
@@ -1,6 +1,6 @@
{ {
"generated_at": "2026-08-08T20:02:03", "generated_at": "2026-08-08T20:19:31",
"git_rev": "f22313d", "git_rev": "c3ae5ad",
"params": { "params": {
"end": "2026-07-24", "end": "2026-07-24",
"sessions": 408, "sessions": 408,
@@ -301,8 +301,8 @@
"band_shares_current": { "band_shares_current": {
"stable": 57.1, "stable": 57.1,
"watch": 30.1, "watch": 30.1,
"elevated": 9.3, "elevated": 1.7,
"breaking": 3.4 "breaking": 11.0
}, },
"soft_gates": [ "soft_gates": [
{ {
@@ -375,8 +375,8 @@
"band_shares_current": { "band_shares_current": {
"stable": 73.0, "stable": 73.0,
"watch": 15.4, "watch": 15.4,
"elevated": 8.1, "elevated": 1.2,
"breaking": 3.4 "breaking": 10.3
}, },
"soft_gates": [ "soft_gates": [
{ {
@@ -421,8 +421,8 @@
"band_shares_current": { "band_shares_current": {
"stable": 78.9, "stable": 78.9,
"watch": 13.0, "watch": 13.0,
"elevated": 7.4, "elevated": 4.7,
"breaking": 0.7 "breaking": 3.4
}, },
"soft_gates": [], "soft_gates": [],
"band_grid": [ "band_grid": [
@@ -695,8 +695,8 @@
"band_shares_current": { "band_shares_current": {
"stable": 77.9, "stable": 77.9,
"watch": 13.7, "watch": 13.7,
"elevated": 7.6, "elevated": 4.7,
"breaking": 0.7 "breaking": 3.7
}, },
"soft_gates": [], "soft_gates": [],
"band_grid": [ "band_grid": [
@@ -969,8 +969,8 @@
"band_shares_current": { "band_shares_current": {
"stable": 78.9, "stable": 78.9,
"watch": 13.0, "watch": 13.0,
"elevated": 7.8, "elevated": 4.7,
"breaking": 0.2 "breaking": 3.4
}, },
"soft_gates": [], "soft_gates": [],
"band_grid": [ "band_grid": [
@@ -1,6 +1,6 @@
# Regime Monitor v4 calibration # Regime Monitor v4 calibration
Generated 2026-08-08T20:02:03 at `f22313d`, 2024-12-05 → 2026-07-24. Generated 2026-08-08T20:19:31 at `c3ae5ad`, 2024-12-05 → 2026-07-24.
## Hard gates ## Hard gates
+34 -6
View File
@@ -127,9 +127,29 @@ def _capped(fn: Callable, cap: float) -> Callable:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Retired formulas (v2) -- reconstructed from git show 019ca13^ # Retired formulas -- reconstructed, no longer in the codebase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _v3_under_200(closes: list[float]) -> float | None:
"""v3's binary trend break, retired when v4 graduated it."""
sma200 = rms._sma(closes, 200)
if sma200 is None:
return None
return 100.0 if closes[-1] < sma200 else 0.0
def _v3_p1_trend_break(smh, qqq, leader_weight: float = 2.0) -> float | None:
return rms._blend(_v3_under_200(smh), _v3_under_200(qqq), leader_weight)
def _v3_p5_volatility(vix: float | None) -> float | None:
"""v3's linear VIX ramp, retired when v4 anchored it. Saturated at 30."""
if vix is None:
return None
return rms._clamp((vix - 15.0) / 15.0 * 100.0)
def _v2_drawdown(closes: list[float]) -> float | None: def _v2_drawdown(closes: list[float]) -> float | None:
if len(closes) < 30: if len(closes) < 30:
return None return None
@@ -186,12 +206,15 @@ def _v2_f2_credit_spreads(oas_values: list[float]) -> float | None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
VARIANTS: dict[str, dict[str, Callable]] = { VARIANTS: dict[str, dict[str, Callable]] = {
# Live code, nothing patched. The reproduction gate runs against this. # Retired since the v4 cutover -- "nothing patched" is now v4, so v3 has to
"v3": {}, # be reconstructed like v2 to stay comparable.
"v4": { "v3": {
"p1_trend_break": _candidate_p1(), "p1_trend_break": _v3_p1_trend_break,
"p5_volatility": _candidate_p5(P5_VIX_ANCHORS_A), "p5_volatility": _v3_p5_volatility,
}, },
# SHIPPED as of v4 -- nothing patched, so this variant exercises live code.
# Keeping a private copy here would let the harness and the service drift.
"v4": {},
"v4-vix-b": { "v4-vix-b": {
"p1_trend_break": _candidate_p1(), "p1_trend_break": _candidate_p1(),
"p5_volatility": _candidate_p5(P5_VIX_ANCHORS_B), "p5_volatility": _candidate_p5(P5_VIX_ANCHORS_B),
@@ -207,6 +230,11 @@ VARIANTS: dict[str, dict[str, Callable]] = {
# so only State statistics and the W1 census are comparable to the published # so only State statistics and the W1 census are comparable to the published
# v2 figures -- not the Warning score. # v2 figures -- not the Warning score.
"v2_reconstruction": { "v2_reconstruction": {
# v2 shared v3's binary trend break and linear VIX ramp verbatim, so both
# are retired now and must be restored here too -- otherwise a "v2" replay
# silently picks up v4's graded sensors.
"p1_trend_break": _v3_p1_trend_break,
"p5_volatility": _v3_p5_volatility,
"p3_drawdown": _v2_p3_drawdown, "p3_drawdown": _v2_p3_drawdown,
"f2_credit_spreads": _v2_f2_credit_spreads, "f2_credit_spreads": _v2_f2_credit_spreads,
# v2 sliced HY_OAS_REFERENCE_YEARS = 10.0 per session. The percentile leg # v2 sliced HY_OAS_REFERENCE_YEARS = 10.0 per session. The percentile leg
+14 -13
View File
@@ -37,13 +37,14 @@ class TestVariantPatching:
def test_patching_restores_even_when_the_body_raises(self): def test_patching_restores_even_when_the_body_raises(self):
original = rms.p5_volatility original = rms.p5_volatility
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
with calib.patched(calib.VARIANTS["v4"]): with calib.patched(calib.VARIANTS["v3"]):
raise RuntimeError("boom") raise RuntimeError("boom")
assert rms.p5_volatility is original assert rms.p5_volatility is original
def test_v3_variant_patches_nothing(self): def test_v4_variant_patches_nothing(self):
"""The reproduction gate must run against live code, not a copy.""" """The shipped methodology must be exercised as live code, not a copy,
assert calib.VARIANTS["v3"] == {} or the harness and the service can drift apart silently."""
assert calib.VARIANTS["v4"] == {}
class TestCandidateFormulas: class TestCandidateFormulas:
@@ -52,24 +53,24 @@ class TestCandidateFormulas:
closes = [100.0] * 200 closes = [100.0] * 200
for last in (105.0, 100.0, 99.0, 92.0, 80.0, 50.0): for last in (105.0, 100.0, 99.0, 92.0, 80.0, 50.0):
series = closes[:-1] + [last] series = closes[:-1] + [last]
v4 = calib._candidate_under_200(series) v4 = rms._under_200(series) # shipped
v3 = rms._under_200(series) v3 = calib._v3_under_200(series) # retired
assert v4 <= v3, f"close={last}: v4 {v4} > v3 {v3}" assert v4 <= v3, f"close={last}: v4 {v4} > v3 {v3}"
def test_anchored_vix_never_exceeds_the_live_formula(self): def test_anchored_vix_never_exceeds_the_live_formula(self):
p5 = calib._candidate_p5(calib.P5_VIX_ANCHORS_A)
for vix in (10, 15, 17, 20, 25, 30, 40, 55, 82): for vix in (10, 15, 17, 20, 25, 30, 40, 55, 82):
assert p5(vix) <= rms.p5_volatility(vix), f"vix={vix}" assert rms.p5_volatility(vix) <= calib._v3_p5_volatility(vix), f"vix={vix}"
def test_the_vix_table_keeps_resolving_past_thirty(self): def test_the_vix_table_keeps_resolving_past_thirty(self):
p5 = calib._candidate_p5(calib.P5_VIX_ANCHORS_A) assert rms.p5_volatility(30) < rms.p5_volatility(40) < rms.p5_volatility(50)
assert p5(30) < p5(40) < p5(50) < p5(55) == 100.0 assert rms.p5_volatility(55) == 100.0
assert rms.p5_volatility(30) == rms.p5_volatility(82) == 100.0 # the defect # the retired formula's defect, kept as the contrast
assert calib._v3_p5_volatility(30) == calib._v3_p5_volatility(82) == 100.0
def test_a_shallow_break_no_longer_pegs(self): def test_a_shallow_break_no_longer_pegs(self):
closes = [100.0] * 199 + [98.0] # ~2% below a flat 200-DMA closes = [100.0] * 199 + [98.0] # ~2% below a flat 200-DMA
assert rms._under_200(closes) == 100.0 assert calib._v3_under_200(closes) == 100.0 # retired: pegged
assert calib._candidate_under_200(closes) < 40.0 assert rms._under_200(closes) < 40.0 # shipped: graded
def test_candidate_tables_are_well_formed(self): def test_candidate_tables_are_well_formed(self):
for table in (calib.P5_VIX_ANCHORS_A, calib.P5_VIX_ANCHORS_B, for table in (calib.P5_VIX_ANCHORS_A, calib.P5_VIX_ANCHORS_B,
+104 -13
View File
@@ -1,4 +1,4 @@
"""Pure-function tests for the v3 AI/Tech Risk Monitor contract.""" """Pure-function tests for the v4 AI/Tech Risk Monitor contract."""
from __future__ import annotations from __future__ import annotations
@@ -172,7 +172,7 @@ def test_relative_strength_flat_or_better_is_zero():
def test_volatility_and_breadth_zero_points(): def test_volatility_and_breadth_zero_points():
assert p5_volatility(15) == 0 assert p5_volatility(15) == 0
assert p5_volatility(30) == 100 assert p5_volatility(30) == 55
assert breadth_level_score(60) == 0 assert breadth_level_score(60) == 0
assert breadth_level_score(20) == 100 assert breadth_level_score(20) == 100
assert breadth_level_score(None) is None assert breadth_level_score(None) is None
@@ -363,7 +363,7 @@ def test_fundamental_api_rejects_numeric_ordinal_overrides():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(monkeypatch): async def test_legacy_numeric_fundamentals_do_not_leak_into_v4(monkeypatch):
async def fake_value(_db, _key): async def fake_value(_db, _key):
return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"}) return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"})
@@ -371,24 +371,29 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(monkeypatch):
result = await rms.get_fundamental_overrides(object()) result = await rms.get_fundamental_overrides(object())
assert result["methodology"] == "v3" assert result["methodology"] == "v4"
assert result["f1_score"] is None assert result["f1_score"] is None
assert result["f3_score"] is None assert result["f3_score"] is None
assert result["good_news_stock_down"] == "mixed" assert result["good_news_stock_down"] == "mixed"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_v2_observation_survives_the_methodology_bump(monkeypatch): @pytest.mark.parametrize("stored_methodology", ["v2", "v3"])
async def test_v2_observation_survives_the_methodology_bump(monkeypatch, stored_methodology):
"""A snapshot reseed must not throw away a hand/LLM-collected observation. """A snapshot reseed must not throw away a hand/LLM-collected observation.
The categorical format is unchanged, so the stored capex map is still valid; The categorical format is unchanged, so the stored capex map is still valid;
only the capex scale moved, and f1 is recomputed from the categories. only the capex scale moved, and f1 is recomputed from the categories.
Parametrised over every methodology that could be sitting in the settings row
at cutover time -- "v3" is the one the v4 bump actually meets in production,
and losing it would silently start a paid LLM refresh on every run.
""" """
names = DEFAULT_CONFIG["tickers"]["hyperscalers"] names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
async def fake_value(_db, _key): async def fake_value(_db, _key):
return json.dumps({ return json.dumps({
"methodology": "v2", "methodology": stored_methodology,
"f1_score": 0.0, # stale v2 scale, must be recomputed "f1_score": 0.0, # stale v2 scale, must be recomputed
"f3_score": 100.0, "f3_score": 100.0,
"capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")}, "capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")},
@@ -405,7 +410,8 @@ async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
assert result["source"] == "gemini" assert result["source"] == "gemini"
assert result["good_news_stock_down"] == "yes" assert result["good_news_stock_down"] == "yes"
assert result["effective_date"] == "2026-07-27" assert result["effective_date"] == "2026-07-27"
assert result["f1_score"] == 37.5 # recomputed on the v3 scale, not the stored 0.0 assert result["f1_score"] == 37.5 # recomputed on the current scale, not the stored 0.0
assert result["fetched_at"] == "2026-07-24T14:25:47+00:00" # or a refresh loop starts
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -484,7 +490,7 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session): async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
snapshot_date = date(2026, 6, 26) snapshot_date = date(2026, 6, 26)
first = { first = {
"methodology": "v3", "methodology": "v4",
"date": snapshot_date.isoformat(), "date": snapshot_date.isoformat(),
"state": {"score": 10.0, "band": "stable"}, "state": {"score": 10.0, "band": "stable"},
"warning": {"score": 20.0, "band": "stable"}, "warning": {"score": 20.0, "band": "stable"},
@@ -540,7 +546,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
return {}, {} return {}, {}
async def fake_latest(_db): async def fake_latest(_db):
return object(), {"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION} return object(), {"methodology": "v4", "sensor_revision": rms.SENSOR_REVISION}
async def fake_upsert(_db, result, *, rewrite_existing): async def fake_upsert(_db, result, *, rewrite_existing):
rewrites.append(rewrite_existing) rewrites.append(rewrite_existing)
@@ -568,9 +574,9 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("stored", "expect_reseed"), ("stored", "expect_reseed"),
[ [
({"methodology": "v3"}, True), # written before the marker existed ({"methodology": "v4"}, True), # written before the marker existed
({"methodology": "v3", "sensor_revision": 1}, True), ({"methodology": "v4", "sensor_revision": 1}, True),
({"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}, False), ({"methodology": "v4", "sensor_revision": rms.SENSOR_REVISION}, False),
], ],
) )
async def test_a_stale_sensor_revision_reseeds_stored_history( async def test_a_stale_sensor_revision_reseeds_stored_history(
@@ -708,6 +714,91 @@ def test_compute_index_uses_one_max_price_vote_and_has_no_combined_score():
price = next(p for p in result["state"]["pillars"] if p["id"] == "price") price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None] sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
assert price["score"] == max(sensor_scores) assert price["score"] == max(sensor_scores)
assert result["methodology"] == "v3" assert result["methodology"] == "v4"
assert "combined" not in result assert "combined" not in result
assert result["basket"]["members_available"] == 25 assert result["basket"]["members_available"] == 25
def test_v4_carries_categorical_fundamental_observations():
"""The costliest failure mode in the v3 -> v4 cut.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES is checked against the *stored* blob.
Omit the current methodology and the first write discards the observation;
the default that replaces it has fetched_at None and locked False, so
_fundamentals_stale is true and update_regime_monitor fires a paid LLM
refresh on every run, forever, with the operator's locked read gone.
"""
assert rms.METHODOLOGY in rms.CATEGORICAL_FUNDAMENTAL_METHODOLOGIES
# Older categorical blobs must still carry forward across the bump.
assert {"v2", "v3"} <= rms.CATEGORICAL_FUNDAMENTAL_METHODOLOGIES
def test_quadrant_dividers_match_the_band_boundaries():
"""The doc asserts dividers sit at each axis's watch/elevated boundary.
Nothing enforced it, and alert_service keeps its own fallback copies -- so a
band move could silently leave the alert path classifying on the old grid.
"""
from app.services import alert_service
assert rms.QUADRANT_STATE_DIVIDER == STATE_BANDS[1]
assert rms.QUADRANT_WARNING_DIVIDER == WARNING_BANDS[1]
assert alert_service.QUAD_X_DIV == rms.QUADRANT_STATE_DIVIDER
assert alert_service.QUAD_Y_DIV == rms.QUADRANT_WARNING_DIVIDER
def test_the_vix_sensor_keeps_headroom_past_a_thirty_print():
"""v3 read VIX 30, 50 and 82 as an identical 100 -- the same saturation v3
itself had just removed from P3."""
assert p5_volatility(30) < p5_volatility(40) < p5_volatility(50)
assert p5_volatility(55) == 100.0
assert p5_volatility(82) == 100.0
assert p5_volatility(15) == 0.0
assert p5_volatility(10) == 0.0
def test_a_shallow_trend_break_does_not_peg_the_price_pillar():
"""v3's binary _under_200 printed 100 the moment price crossed, pinning the
pillar's max() and stopping P3's ladder resolving for the whole selloff."""
end = date(2026, 6, 26)
# ~2% below a flat 200-DMA, with a shallow drawdown to match.
flat = [100.0] * 260
shallow = flat[:-1] + [98.0]
prices = {
"SMH": _dated(shallow, end),
"QQQ": _dated(shallow, end),
"SPY": _dated(flat, end),
}
result = _compute_index(
prices, [(end, 16.0)], [(end, 2.8)], {},
copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 0.0)], {end: 30},
)
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
assert price["score"] < 40.0, "a 2% break must not read as maximum stress"
p1 = next(s for s in price["sensors"] if s["id"] == "P1")
assert 0.0 < p1["score"] < 40.0
def test_anchor_tables_are_well_formed():
"""Cheap guard against a fat-fingered edit to any interpolation table."""
tables = {
"P3_DRAWDOWN_ANCHORS": rms.P3_DRAWDOWN_ANCHORS,
"P1_TREND_BREAK_ANCHORS": rms.P1_TREND_BREAK_ANCHORS,
"P5_VIX_ANCHORS": rms.P5_VIX_ANCHORS,
}
for name, table in tables.items():
xs = [x for x, _ in table]
ys = [y for _, y in table]
assert xs == sorted(xs) and len(set(xs)) == len(xs), f"{name}: x not increasing"
assert ys == sorted(ys), f"{name}: y not non-decreasing"
assert 0.0 <= min(ys) and max(ys) <= 100.0, f"{name}: out of [0,100]"
# Slopes ease off only on the two v4 tables. P3 is deliberately gentle at the
# onset then steepens (2.5, 3.75, 3.125, 2.33, 1.83), so it is excluded.
for name in ("P1_TREND_BREAK_ANCHORS", "P5_VIX_ANCHORS"):
table = tables[name]
slopes = [
(table[i + 1][1] - table[i][1]) / (table[i + 1][0] - table[i][0])
for i in range(len(table) - 1)
]
assert all(a >= b for a, b in zip(slopes, slopes[1:])), f"{name}: {slopes}"