The page had twelve stacked blocks, several of them different views of the same numbers. The quadrant plot and the score-history chart drew the same two series from the same query key, which read as two datasets; they are now one card with a Time | Path toggle. The two pillar disclosures become one grouped table, and three prose blocks (data quality, basket, coverage) become one provenance chip strip. Page text is now limited to what changes how the reader interprets today's number; the rest moved to the methodology doc. Removes three stale-threshold bugs of one class. The quadrant fell back to v2's 60/60 dividers when quadrant_config was absent -- the real values are 50/40 and they feed alert_service, so the chart could disagree with what actually fires. The gauge fell back to v2's 30/60/80 band ticks, and drew a divider line that always landed on its own "elevated" tick. The time series' reference lines were at 30/60/80, which correspond to nothing in v3; they are now per-axis dashed lines read from the same quadrant_config. Rendering also surfaced a live clipping bug inherited from the old chart: margin.left -18 against YAxis width 28 left ~10px for a 3-digit label, so every Y tick was cut off. HY_OAS_WINDOW_DAYS was 400 *calendar* days while a rebuild replays REBUILD_SESSIONS = 400 *trading* sessions (~579 calendar days), so the oldest ~180 days of any rebuild got no OAS at all and both credit sensors returned None. State then lands at 80% coverage and Warning at exactly MIN_COVERAGE, so both still publish bands -- a series that looks homogeneous while its oldest rows were scored without credit. Widened to 700. This needs no methodology bump: C1 reads [-1] and W3 reads [-21], both from the end, so widening only prepends and every live score is bit-identical. Sequenced deliberately, since acting on the open findings below bumps METHODOLOGY and fires the rebuild. A just-collected fundamental observation was hidden until its effective date -- one day, three over a weekend -- because the live reading called the point-in-time function, so refreshing appeared to do nothing. That was the opposite of what the doc claimed. fundamental_overlay stays the gated record (it runs for every replayed date during a rebuild); current_observation is the live reading and reports the effective date instead of blanking the content. Nothing in the overlay is scored, so showing it early cannot reach a published number. Documents four calculation findings. Three are not implemented, since each changes a published score and so requires a v4 cut: State's top band is a credit-event band (credit returns 0.0 rather than None below the 3.5 anchor, so it is pinned at zero at weight 20 -- with everything else pegged State computes to exactly 80.0, the breaking threshold); V1 saturates at VIX 30; and the deliberate max(P1,P2,P3) defeats P3's anchoring because P1 is binary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
16 KiB
Markdown
290 lines
16 KiB
Markdown
# Regime Monitor v3 methodology
|
||
|
||
The Regime Monitor is an observational AI/Tech risk thermometer. It does not
|
||
gate entries, exits, position size, ranking, or alerts about individual setups.
|
||
|
||
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.59–4.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").
|
||
|
||
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.
|
||
|
||
The fix was sequenced deliberately: acting on items 1–3 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.**
|