Files
signal-platform/docs/research/regime-monitor-v4.md
T
dennisthiessenandClaude Opus 5 333989eeab
Deploy / lint (push) Failing after 11s
Deploy / test (push) Skipped
Deploy / deploy (push) Skipped
feat(risk-monitor): measure the rule that fires, and give fundamentals their own channel
The Warning study measured a fitted percentile crossing that nothing consumes.
What reaches Telegram is a quadrant change: fixed 50/40 dividers, hysteresis,
two-session confirmation, 3-day cooldown. Those thresholds are constants, not
fits, so there is no training set to protect and all 11 detected corrections are
evaluable instead of the 4 that fell in a holdout.

Replaying it: 1/10 corrections, 0.9 false alarms/year. Random alarms at the same
firing rate match or beat that in 65% of draws. The panel now carries ablations
(does the quadrant machinery earn its place?), external baselines (does the score
earn its complexity?), and that null, because a bare "2 of 4" was unreadable in
either direction. Nothing in the alert path was retuned on the strength of it.

Fundamentals become a third channel rather than a term in either score. v3 cut
them arguing 12+8 of 100 points "could not change any published conclusion" --
true only when every technical sensor reads zero; weighted they moved the bar for
the 40 divider from 40 to 25. But no fusion weight is measurable either: with ~10
events and no fundamental history, any weight is a policy preference presented as
a measurement. So the read is a categorical state (supportive/neutral/adverse/
unknown) with an evidence grade, derived by fixed rules from stored facts, read
by confluence. The LLM extracts and explains; it does not score.

Absence stays absence throughout. `unknown` is unreachable by averaging, a stale
or empty observation may display but never confirm, extraction failures map to
`unknown` rather than `mixed`, and the study rows are coverage-matched and marked
not-measurable until enough corrections are covered -- otherwise a fortnight of
observations renders as 0/10 and reads as a failed test.

Observations become a real time series (migration 033); they lived in a single
overwritten settings slot, so no history existed to replay. Pre-rename snapshots
are adapted rather than discarded. METHODOLOGY stays v4 -- no score changed --
so no reseed; STUDY_SCHEMA moves to 3 and discards the cached report.

Post-deploy: re-run Event Study from Admin -> Jobs. The panel reads "not run yet"
until then.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:15:09 +02:00

838 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/run_regime_monitor_calibration.py --methodology v2_reconstruction,v2_reconstruction_oas400,v3,v4,v4-vix-only,v4-p1-only --cache-dir .calib-cache
```
`v3` and `v4` are mandatory — the row-wise `state_v4 <= state_v3` invariant is
a hard gate and needs both — and the replayed **start** date is asserted
against the published window. The session *count* alone proves nothing, since
the harness slices the tail of the price series to whatever was asked for.
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.
## The fundamental channel (2026-08-12)
The monitor has **three channels**, not two scores with a decoration:
- **State** — current observable technical stress (price, breadth, credit, volatility).
- **Warning** — observable deterioration that may precede stress (breadth
divergence, relative strength, credit impulse).
- **Fundamental context** — a categorical state (`supportive` / `neutral` /
`adverse` / `unknown`) with an `evidence_quality` grade.
The third is **never a term in the other two**. They are read together by
confluence:
| Warning | Fundamentals | Reading |
|---|---|---|
| Calm | Supportive/neutral | Normal |
| Elevated | Supportive/neutral | Technical warning, not fundamentally confirmed |
| Calm | Adverse | Fundamental concern; tape has not confirmed |
| Elevated | Adverse | Confluence — highest attention |
`METHODOLOGY` stays **v4**: no score changed, so partitioning the history API and
discarding the event study cache would be churn. `STUDY_SCHEMA` moved to 3
instead, and is now the only thing that discards a stale report.
### Why the read is a channel and not a weight
Two things are true at once, and only this shape honours both.
**v3's reason for removing fundamentals from the score was wrong.** Not stale —
wrong. v3 argued that F1 (capex) and F3 (good-news-stock-down), carrying 12 + 8
of 100 Warning points, "could not change any published conclusion" because pegged
they produced a Warning of exactly 20.0, below the alarm threshold. That
arithmetic holds only when *every* technical sensor reads exactly zero, which is
the one case that never matters. Warning is a weighted average, so the sensors
add:
| technical Warning | without fundamentals | with them pegged | delta |
|---|---|---|---|
| 0 | 0.0 | 20.0 | +20.0 |
| 20 | 20.0 | 36.0 | +16.0 |
| 25 | 25.0 | **40.0** | +15.0 |
| 35 | 35.0 | **48.0** | +13.0 |
| 50 | 50.0 | 60.0 | +10.0 |
| 80 | 80.0 | 84.0 | +4.0 |
Pegged fundamentals lowered the technical Warning needed to reach the 40 quadrant
divider from 40 to 25. That is a 15-point shift in where the alert fires, which
is emphatically a changed conclusion. The v3 section below is kept as written,
with this correction attached, because its reasoning is cited elsewhere in this
file and a silent overwrite would hide that the error was ever made.
**But no weight is measurable either.** A weighted modifier was built and
reverted: 025 points added onto the technical Warning, sized so a maxed-out read
carried a calm tape over the 40 divider on its own. Nothing could justify the 25.
With ~10 correction events and essentially no fundamental history, any fusion
weight is a policy preference presented as a measurement — and the debate it
invites ("does the read deserve 10%, 20%, 30%?") has no evidence that can settle
it. Adding a slow categorical judgement to a fast continuous score also
manufactures precision by summing unlike things, and it forces a missing
observation to silently redistribute its weight onto the technical sensors, which
is the opposite of leaving it unknown.
So: the read gets a channel, not a coefficient. Both facts survive — the v3
removal was badly argued *and* no weight is defensible — because "report it
separately" is the only design that neither buries the observation nor invents a
number for it.
### Derivation
Deterministic, from the stored categorical facts. The LLM is an **extraction and
explanation layer**: it finds the capex guidance, classifies it, and cites it.
Fixed rules turn those facts into a state, so the same observation always yields
the same category.
`capex_signal`: any `cutting` → adverse; else any `holding` → neutral; else all
known `raising` → supportive; nothing known → unknown.
`reaction_signal`: `yes` → adverse, `mixed` → neutral, `no` → supportive,
`unknown` → unknown.
`mixed` and `unknown` are different reaction states and were merged until
2026-08-13. A failed LLM parse fell back to `mixed`, so an extraction error
became *neutral evidence* — an observation of normality manufactured out of a
bug. `mixed` now means an observed mixed reaction; anything unreadable, missing
or unattempted is `unknown` and contributes nothing.
Combined by precedence, never by averaging: **any adverse read carries**; both
unknown → unknown; every observed signal supportive → supportive; otherwise
neutral.
`unknown` is deliberately unreachable by combination. Averaging would let two
`cutting` reads and two `unknown` ones land on "neutral", presenting missing
evidence as evidence of normality — the same conflation `current_observation`
already refuses between "no observation" and "an observation of zero". Two cuts
and two unknowns read **adverse with `evidence_quality: partial`**.
`evidence_quality` is ordered by what an operator needs first: `unavailable`
(nothing collected) → `stale` (past `fundamental_staleness_days`) → `manual`
(hand override) → `complete` / `partial`.
### Presentation and alerts
The Path view colours each dot by the fundamental state recorded that day; the
axes are untouched, because context is confluence information rather than a
position on either axis. The card leads with the state and evidence grade.
Alerts stay **separate**, off one toggle:
- quadrant change — the market axes moved (existing);
- `regime_fundamental` — the context changed, e.g. neutral → adverse;
- `regime_confluence` — Warning elevated *and* fundamentals adverse.
`unknown` never alerts: an absence of evidence is not a change in the evidence,
and alerting on it would train the reader to ignore the channel. Both new
triggers seed silently on first run, as the quadrant alert does.
### The observation is now a real time series
`regime_fundamental_observations` (migration 033), one row per `effective_date`,
upserted. Before this it lived in a single `SystemSetting` slot that every
refresh overwrote, so no history existed at all — which made the read impossible
to replay, impossible to backtest, and meant a rebuild recorded every historical
session as if nothing had been observed. `update_regime_monitor` carries the
pre-existing single-slot observation into the series on its next run.
### What this does not establish
The table starts empty and fills one observation at a time, so the fundamental
rows are **untested, not failed**. Two things enforce that rather than one:
- they are **coverage-matched** — scored only on sessions where the channel had
usable context and on corrections whose warning horizon fell inside it, with a
market-only comparator over the identical window so any difference between them
is the channel and not the window;
- `measurable` stays false until `MIN_EVENTS_FOR_CONFIDENCE` corrections are
covered, and the panel prints "insufficient exposure" rather than a ratio.
Without the first, one day of coverage would render as 0/10 — recreating, one
observation later, exactly the tested-versus-unavailable confusion the flag was
added to prevent. The market rows are unchanged, and the 1/10 shipped-rule figure
remains a verdict on the technical sensors and the alert machinery alone.
The rationale for expecting the read to matter is the operator's: hyperscaler
capex is the demand side of the entire AI trade, and good earnings being sold is
a classic late-cycle tell. Both are plausible. Neither is measured here, and this
file's convention is that published numbers are reproducible.
**The path forward is accumulation, then a test — in that order.** Once enough
point-in-time observations exist, test whether the state improves prediction
*conditional on* Warning. If it does, a fitted and calibrated model has something
to fit; until then there is nothing to calibrate against. Backfilling would get
there faster: capex direction is derivable from the 10-Q/10-K capex line, which
the SEC fundamentals import already carries, and "good news, stock down" from
earnings dates plus next-day returns, which the Dolt earnings import already
carries. That last one is worth computing deterministically rather than asking
the LLM to judge, for the same reason the state derivation is rule-based.
## 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.
The `P1_SCORE_CAP` fallback drafted during design was to fire if P1 became the
sole price argmax on **more than 80% of sessions with State ≥ 40** — i.e. if it
had quietly become a second drawdown sensor. Measured on that population: 47
qualifying sessions, P1 sole argmax on **17 of them (36.2%)**, against P2's 16
and P3's 14. Well under the threshold, so the cap is 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.
> **Corrected 2026-08-12.** The claim in this paragraph is false. "Pegged
> they produced a Warning of exactly 20.0" describes only the case where every
> technical sensor reads zero; Warning is a weighted average, so in the general
> case those 20 points added +10 to +20 and moved the technical score needed to
> reach the 40 quadrant divider from 40 to 25. The observation was removed for
> being *underweighted*, on reasoning that mistook a corner case for the whole
> range. See "The fundamental channel" above for what replaced it — a separate
> categorical channel, not a restored weight. The capex `holding` rescale in the second half
> of this paragraph stands and is still live.
**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%.
**Fundamental context** — a categorical third channel, not a term in either
score. See "The fundamental channel" above.
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 P1 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.
**Which OAS window the published v2 figures used.** v2 requested 13 years of HY
OAS and sliced `HY_OAS_REFERENCE_YEARS = 10.0` per session; ICE serves only ~3
years (778 observations from 2023-08-08), so the effective window was that. But
production v2 also fetched only 400 *calendar* days at one point — the bug fixed
2026-08-07 — and whether the published numbers predate that was not recoverable
from the text. Settled by replay rather than assumed: the
`v2_reconstruction_oas400` variant truncates the OAS **source series** to 400
days (patching the per-session window cannot simulate data that was simply
absent) and yields avg 26.54, p80 42.52, max **100.00**, against published
22.6 / 35.1 / 91.2. Full coverage reproduces all three. So the published figures
correspond to the untruncated fetch.
**The top VIX anchors are exercised, not just asserted.** The window contains a
52.33 close (2025-04-08), so the 40 → 80 → 55 → 100 segment is fed by real data
rather than justified from long-run history alone.
## Point-in-time record
The first run under a new `METHODOLOGY` rebuilds every session inside
`REBUILD_LOOKBACK_DAYS` — 672 calendar days, roughly 464 trading sessions;
routine runs thereafter insert/update only the latest trading date. The bound is
in calendar days rather than a session count because the binding constraint is
the OAS fetch: each replayed row needs W3's lookback inside
`HY_OAS_WINDOW_DAYS`, so replaying further back would recreate the credit gap a
reseed exists to close. 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 channel 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. Since the observations became a real
series (`regime_fundamental_observations`, migration 033), the effective-date
lookup *is* the gate: a replayed session gets whichever observation was live on
it, and sessions before the first one read `unknown`.
Two functions, deliberately: `fundamental_context` 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_live`, 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 score, because
nothing in the channel is scored.
`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, since 2026-08-13, `unknown` 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_context` never had this
problem: no observation means no effective date, which means `pending`, which
already blanks the content.
**`usable` is what may confirm; `available` is only what to display.** Three
distinct things, and collapsing any two of them is a bug:
- `state` — the last thing observed. Survives going stale, so the card can show it.
- `available`*timing*: there is an effective, non-stale record to display.
- `usable`*content*: available **and** the observation actually determined
something (`state != "unknown"`).
The confluence alert and all three coverage-matched study rules gate on `usable`.
Gating on `available` instead has two failure modes, and both were live at some
point in this design:
1. a reading past `fundamental_staleness_days` would corroborate every Warning
crossing indefinitely — the strongest claim this channel makes, from the data
with the least right to make it;
2. an LLM run that failed to extract anything produces a perfectly fresh
observation that knows nothing. Counting it as exposure means repeated
extraction failures slowly accumulate coverage until the fundamental rows flip
to a *measurable* 0/8 — a failed result published for a channel that never saw
a thing, which is precisely what coverage-matching exists to prevent.
**Pre-rename snapshots are adapted, not discarded.** The channel was stored as
`fundamental_overlay` until 2026-08-12. The rename shipped without a methodology
bump — no score changed — so those rows are still served and were never reseeded.
Reading only the new key would have turned every one of them into `unknown`,
silently dropping real recorded evidence: historical Path colours, and exposure
the event study can legitimately count. `_parse_snapshot` derives the channel
from a legacy overlay's own stored facts (its capex map supplies the basket, so
the derivation uses the names observed at the time rather than today's config).
Normalising there rather than at each call site means no reader can receive an
un-adapted row. Delete only after a reseed has rewritten the whole window.
**The blob and the series row are one transaction.** They are the same
observation seen by the live card and by the point-in-time replay; committing
them separately leaves a window where a failure publishes one and not the other,
and the two then disagree permanently with nothing to detect it. Both writers use
`settings_store.upsert_setting` (which does not commit) plus a single commit;
`record_fundamental_observation` deliberately takes no commit of its own so
`update_regime_monitor` keeps its own transaction boundary.
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. It measures
two rules against that outcome, plus enough context to tell whether either number
is any good.
A cached report is discarded when its methodology no longer matches *or* when
`STUDY_SCHEMA` moves, so the panel reverts to "not run yet" rather than showing
stale numbers or a report missing half its blocks. **Re-run the Event Study job
after a methodology cutover or a schema bump.**
### The headline is the rule that actually fires
Until 2026-08-12 the study measured a bare rising-edge crossing of an
80th-percentile threshold fitted on the first 70% of sessions. **Nothing consumes
that rule.** What reaches Telegram is `_collect_regime_quadrant`: a quadrant
change with State ≥ 50 and Warning ≥ 40 as fixed dividers, a ±5 hysteresis
deadband, a two-session confirmation, a 3-day cooldown, and a 75% coverage gate
on both axes. The two differ on every one of those axes, including the threshold
itself (a fitted ~32 against a shipped 40).
`replay_quadrant_changes` replays the shipped state machine over the whole
sample. Three details are reproduced rather than cleaned up, because a state
machine written from first principles gets each of them wrong:
- the prior session is classified against the **current baseline**, not against
its own predecessor, so confirmation asks "did yesterday already look like this
change" rather than "did yesterday change too";
- the baseline advances only when an alert actually fires, so a change blocked by
confirmation or cooldown is re-evaluated against the old quadrant next session;
- one cooldown is shared by every quadrant change, so a 3→4 alert can swallow a
4→2 alert three days later.
Two consequences worth stating. The alarm is dated at the **confirmation**, not
at the first crossing, which costs one session of lead by construction. And the
rule alerts on changes in both directions, so the replay's exits are recorded but
filtered out by `entry_alarms` — only entering a Warning-high quadrant is a
warning about anything.
The replay reuses `_compute_index` rather than re-deriving the axes. That is the
same anti-drift argument that produced `warning_sensor_scores`: the v2 study
re-derived Warning by hand and would have kept measuring the old construct
through a scoring change. State has no equivalent shared helper, so the snapshot
builder itself is the shared definition.
**Nothing is fitted, so nothing needs protecting from a training set.** There is
no split, and every detected correction is evaluable instead of the four that
happen to land in the last 30%. The `underpowered` and "threshold frozen on a
different construct" caveats do not apply to this variant.
### Reading the result
A bare "2 of 4" is unreadable in either direction, so the report scores four more
rules through the same `evaluate_alarms` harness over the same events and
sessions, and adds a null. All use fixed thresholds — a threshold fitted on the
full sample would have lookahead the shipped rule does not, and one fitted on a
split could only be scored on the holdout events.
| kind | rules | the question |
|---|---|---|
| ablation | Warning ≥ 40 bare, State ≥ 50 bare | does the quadrant machinery earn its place? |
| baseline | leader below its 50-DMA, VIX ≥ 20 | does the score earn its complexity? |
| null | K random alarms at the observed firing rate | is any of this better than chance? |
The two kinds must not be read as one list. If a baseline matches the score, the
composite is not earning its complexity and that is the finding — it does not
mean the monitor is worthless, since State and Warning exist to be *read*, but it
caps how much further calibration is justified. If the bare Warning crossing
beats the shipped rule, the machinery (not the sensor) is what is costing recall.
The null draws only from sessions a rule could actually have fired on. Over the
whole sample it would be diluted by warm-up sessions and would understate what
chance achieves — which matters, because with ~11 events and a 20-session horizon
roughly a sixth of the sample already sits inside a hit window. It is seeded, so
a re-run cannot move the report. Corrections cluster and uniform placement does
not, so it is the **floor, not the bar**: an alarm process that clustered would
beat it for reasons unrelated to foresight.
### First result (2026-08-12): the shipped rule is not distinguishable from chance
Replayed over 2021-07-14 → 2026-08-12. The 200-DMA warm-up means the baseline
only seeds on 2022-05-26, so 1056 of 1276 sessions are evaluable and 10 of the 11
detected corrections fall inside them.
| rule | kind | warned | FA/yr | median lead |
|---|---|---|---|---|
| **Quadrant alert (shipped)** | | **1/10** | **0.9** | 19d |
| Quadrant alert, both axes high | ablation | 0/10 | 0.9 | — |
| Warning ≥ 40, bare crossing | ablation | 3/10 | 4.8 | 20d |
| State ≥ 50, bare crossing | ablation | 0/10 | 0.7 | — |
| SMH below its 50-DMA | baseline | 7/10 | 6.7 | 8d |
| VIX ≥ 20 | baseline | 4/10 | 7.2 | 9.5d |
| Random alarms, same firing rate | null | 0.9 ± 0.8 | — | — |
**P(chance ≥ 1/10) = 0.65.** Alarms scattered at random over the same sessions at
the rule's own firing rate match or beat it two times in three. Whatever the
score knows, this rule is not transmitting it.
Three readings, in order of how much they should change:
**The machinery costs more than it protects.** The bare Warning crossing catches
3 with a 20-session lead; wrapping it in the quadrant rule drops that to 1. The
State condition is the largest single cost — requiring both axes high catches
nothing at all, which is what a coincident axis gating a leading one predicts.
Hysteresis, the two-session confirmation and the shared cooldown between them
take the rest, and the cooldown is shared across *every* quadrant change, so
exits consume the budget that entries need. Only 5 of the 15 replayed changes are
Warning-high entries.
**The crude baselines beat everything on recall, at a price.** SMH below its
50-DMA catches 7 of 10 — but at 6.7 false alarms a year against the shipped
rule's 0.9. That is a 7× recall improvement for 7× the noise, so it is not a
clean dominance and this table cannot settle it; the missing axis is what a false
alarm actually costs, which nothing here measures. What it does settle is that
the composite is not buying recall the 50-DMA does not already have.
**The 0.9 false alarms/year is not the achievement it looks like.** A rule that
almost never fires has few false alarms by construction. Read the two columns
together or not at all.
Recorded from an offline replay (live Alpaca + FRED, no database, breadth
computed from the same Alpaca closes rather than the stored universe). The job in
Admin → Jobs is the canonical path and reads breadth from the DB, so re-run it to
confirm these figures before treating them as the record.
**This is a verdict on the market channels only.** The fundamental and confluence
rows in the same table are marked `measurable: false` and print "not measurable"
rather than a ratio: with an empty observation series they never fire, and a 0/10
sitting in a comparison column would read as tested-and-failed. `false` here means
the input does not exist yet, not that the rule lost.
(The figures above were also produced under a briefly-built weighted modifier and
came back bit-identical, which is what confirmed the modifier was inert over the
whole window — the numbers depend on the technical sensors alone either way.)
**Not acted on.** Nothing in the alert path was changed on the strength of this.
The obvious candidates — dropping the State condition from the entry test,
separating the entry and exit cooldowns, or lowering the Warning divider — are
threshold changes to a live alerting rule and want their own decision.
### The coverage gap relocates, it does not close
Dropping the fitted threshold makes the whole sample evaluable, but most of the
extra events predate 2023-08. W3 does not exist there, so Warning renormalises to
`(W1×45 + W2×30)/75` and the fixed 40 divider is applied to a different construct
than it was reasoned about. The report therefore splits shipped-rule metrics at
the credit sensor's first session and the panel states both, because replacing
one misleading headline with a differently misleading one would be no gain.
Convenient side effect: the pre-credit era *is* the "Warning without W3"
ablation, measured on real sessions rather than simulated ones, so that ablation
is not run separately.
Alarms and events are assigned to eras by index, so an alarm days before the
boundary matching an event days after it lands in the earlier era. With the eras
years long and the events sparse, that costs nothing.
### The fitted variant, kept for continuity
The 70/30 percentile study is still computed and still reported, collapsed, with
its `reliability` block intact — it is a genuinely different question, and it is
what earlier revisions of this document report. Its caveats stand:
**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 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. The headline recall does not currently discount those; a minimum-lead
rule is the obvious next change and has not been made.
**Sensor coverage straddles 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 a fitted
threshold is hypersensitive to window choice at this sample size — which is the
strongest argument for making the unfitted shipped rule the headline.
### Considered and not done
**An ETF credit proxy (HYG/IEF) to extend W3 back over the whole sample.** It
would trade "two sensors versus three" for "proxy sensor versus real sensor" —
still a construct straddle, but no longer flagged by the coverage split. This is
the same objection that rejected `BAA10Y` as a percentile reference. If ever
revisited, check the impulse correlation on the three years of real-OAS overlap
first and report it as a sensitivity, never as the headline.
**A depth sweep (5%/7%/15% corrections) for more events.** `EVENT_COOLDOWN_DAYS`
is 40, so at shallower thresholds re-triggers inside a single decline merge or
drop and the denominator moves for cooldown reasons rather than market ones.
## 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.**