Compare commits
6
Commits
f22313deaf
...
d02fd82ced
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02fd82ced | ||
|
|
43ee619412 | ||
|
|
87224a1451 | ||
|
|
ec1b0acfad | ||
|
|
3143477a62 | ||
|
|
c3ae5ad949 |
@@ -54,3 +54,6 @@ reports/.cache/
|
||||
# Runtime A5 parity bundles are generated on the production server. Research
|
||||
# conclusions belong in docs/research, not as an ever-growing artifact archive.
|
||||
reports/fundamentals-parity/
|
||||
|
||||
# Calibration harness raw-pull cache (Alpaca/FRED); regenerable, not a record.
|
||||
.calib-cache/
|
||||
|
||||
@@ -133,7 +133,7 @@ indicators.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**Near-close** (~15:30 ET Mon–Fri) — the only full-universe qualifying observation:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AI/Tech Risk Monitor v3.
|
||||
"""AI/Tech Risk Monitor v4.
|
||||
|
||||
The monitor is a risk thermometer, not a probability or trading rule. It keeps
|
||||
two deliberately separate outputs:
|
||||
@@ -8,13 +8,13 @@ two deliberately separate outputs:
|
||||
relative strength, credit impulse).
|
||||
|
||||
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
||||
earnings-reaction observations are a qualitative *overlay* in v3 rather than
|
||||
earnings-reaction observations are a qualitative *overlay* since v3 rather than
|
||||
weighted sensors: at a combined 20 points they could not reach the event
|
||||
study's alarm threshold even when both pegged, so refreshing them appeared to
|
||||
do nothing. They are reported next to the scores instead of inside them.
|
||||
|
||||
Daily snapshots are the point-in-time record. The first run under a new
|
||||
``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once;
|
||||
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
|
||||
ordinary runs thereafter only upsert the latest trading date. The overlay is
|
||||
still gated by its effective date so a rebuild cannot stamp today's observation
|
||||
onto historical snapshots.
|
||||
@@ -48,10 +48,14 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
|
||||
KEY_CONFIG = "regime_monitor_config"
|
||||
KEY_FUNDAMENTALS = "regime_fundamental_overrides"
|
||||
|
||||
METHODOLOGY = "v3"
|
||||
METHODOLOGY = "v4"
|
||||
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
||||
# collected by hand/LLM and carried across it when the format is compatible.
|
||||
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
||||
# EVERY methodology sharing the categorical format must be listed: this is checked
|
||||
# against the *stored* blob, so omitting the current one discards the observation
|
||||
# on its first write, which leaves fetched_at null and locked false -- and then
|
||||
# update_regime_monitor refreshes it via the LLM on every single run, forever.
|
||||
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4"})
|
||||
|
||||
# Bumped when a fix changes what historical rows *should* contain without
|
||||
# changing the live formula, so stored history needs one reseed. Deliberately
|
||||
@@ -59,6 +63,10 @@ CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
||||
# study, neither of which is warranted here -- the study recomputes its Warning
|
||||
# series from source rather than reading snapshots, so a reseed cannot stale it.
|
||||
# Snapshots written before this marker existed carry no key and read as 1.
|
||||
# Deliberately NOT bumped for v4: a METHODOLOGY change already forces a full
|
||||
# reseed (every stored row fails _parse_snapshot, so _latest_snapshot_row returns
|
||||
# None and rebuilding is True). Bumping both would imply the reseed was
|
||||
# revision-driven.
|
||||
SENSOR_REVISION = 2
|
||||
MIN_COVERAGE = 75.0
|
||||
SOURCE_MAX_LAG_DAYS = 7
|
||||
@@ -68,9 +76,18 @@ SOURCE_MAX_LAG_DAYS = 7
|
||||
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
|
||||
# numbers chosen so each band covers a sane share of history, not percentile
|
||||
# fits -- percentile-derived bands would drift on every rebuild and silently
|
||||
# rewrite what past snapshots meant. Realized shares over the 408 sessions to
|
||||
# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%.
|
||||
STATE_BANDS = (20.0, 50.0, 80.0)
|
||||
# rewrite what past snapshots meant.
|
||||
#
|
||||
# v4 moved State's top band 80 -> 65, and only that one. With credit calm it
|
||||
# scores 0.0 (not None) and still holds its full 20 points, so price + breadth +
|
||||
# volatility at *literal maximum* summed to exactly 80.0 -- the old threshold, to
|
||||
# the decimal, with nothing to spare. A 2022-style AI/tech drawdown with calm
|
||||
# credit computes to 70.3-74.0 depending on whether a death cross has formed, so
|
||||
# at 80 the case this monitor exists to measure could not print the top band.
|
||||
# 65 clears it under either assumption. Realized shares over the 408 sessions to
|
||||
# 2026-07-24, reported not fitted: State 78.9/13.0/4.7/3.4%, Warning 69/20/8/3%.
|
||||
# The v4 breaking share (3.4%) matches v3's, which was arrived at independently.
|
||||
STATE_BANDS = (20.0, 50.0, 65.0)
|
||||
WARNING_BANDS = (20.0, 40.0, 60.0)
|
||||
|
||||
QUADRANT_STATE_DIVIDER = 50.0
|
||||
@@ -118,6 +135,27 @@ P3_DRAWDOWN_ANCHORS = (
|
||||
(0.0, 0.0), (4.0, 10.0), (8.0, 25.0), (16.0, 50.0), (28.0, 78.0), (40.0, 100.0),
|
||||
)
|
||||
|
||||
# Trend-break depth (% below the 200-DMA, stress score). v4; see _under_200 for
|
||||
# why the crossing gets a floor of 20 rather than starting at 0. Calibrated to
|
||||
# sit alongside P3 rather than swamp it -- the 200-DMA lags, so a 20% drawdown
|
||||
# typically coincides with ~10% below the average, where this reads ~61 against
|
||||
# P3's ~59. On the population the P1_SCORE_CAP rule actually names -- sessions
|
||||
# with State >= 40 -- P1 is the sole price argmax on 17 of 47 (36.2%), against
|
||||
# P2's 16 and P3's 14, so it informs the pillar without owning it and no cap
|
||||
# was needed.
|
||||
P1_TREND_BREAK_ANCHORS = (
|
||||
(0.0, 20.0), (3.0, 35.0), (8.0, 55.0), (15.0, 75.0), (25.0, 100.0),
|
||||
)
|
||||
|
||||
# VIX level anchors (v4). Full scale at 55 rather than at 2020's ~82: anchoring
|
||||
# the top at a once-in-a-generation print would make VIX 50 -- a genuine crisis
|
||||
# -- read only ~70. A typical correction (25-35) now reads 38-67 where v3 read
|
||||
# 66.7-100. The anchors encode the long-run distribution as constants, the same
|
||||
# argument the credit level uses.
|
||||
P5_VIX_ANCHORS = (
|
||||
(15.0, 0.0), (20.0, 20.0), (25.0, 38.0), (30.0, 55.0), (40.0, 80.0), (55.0, 100.0),
|
||||
)
|
||||
|
||||
STATE_WEIGHTS = {
|
||||
"price": 40.0,
|
||||
"breadth": 25.0,
|
||||
@@ -219,10 +257,24 @@ def band_for(score: float, bands: tuple[float, float, float] = STATE_BANDS) -> s
|
||||
|
||||
|
||||
def _under_200(closes: list[float]) -> float | None:
|
||||
"""Trend break graded by depth below the 200-DMA, not a bare yes/no.
|
||||
|
||||
Through v3 this returned 0 or 100, so P1 printed 100 the moment SMH and QQQ
|
||||
were both under their average -- and because the price pillar takes
|
||||
``max(P1, P2, P3)``, that pinned the pillar and stopped P3's anchored ladder
|
||||
resolving anything for the whole of a selloff. It pegged on 46 of the 408
|
||||
sessions to 2026-07-24; under this table, none.
|
||||
|
||||
The step at the crossing (0 -> 20) is deliberate: the break itself is a
|
||||
genuine binary event and deserves a floor. Only the depth past it is graded.
|
||||
"""
|
||||
sma200 = _sma(closes, 200)
|
||||
if sma200 is None:
|
||||
if sma200 is None or sma200 <= 0:
|
||||
return None
|
||||
return 100.0 if closes[-1] < sma200 else 0.0
|
||||
pct_below = (sma200 - closes[-1]) / sma200 * 100.0
|
||||
if pct_below <= 0:
|
||||
return 0.0
|
||||
return _clamp(_interpolate(pct_below, P1_TREND_BREAK_ANCHORS))
|
||||
|
||||
|
||||
def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
|
||||
@@ -288,9 +340,17 @@ def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60)
|
||||
|
||||
|
||||
def p5_volatility(vix: float | None) -> float | None:
|
||||
"""VIX level against named anchors, so it keeps resolving past a 30 print.
|
||||
|
||||
v3 used ``(vix - 15) / 15``, which reached 100 at VIX 30 -- the same
|
||||
saturation v3 itself had just removed from P3. VIX 30 is a bad week, 50 is a
|
||||
crisis and 82 was March 2020, and all three scored identically. In the 408
|
||||
sessions to 2026-07-24 that flattened five distinct April-2025 prints
|
||||
(52.33, 46.98, 45.31, 40.72, 38.57) into a single 100.
|
||||
"""
|
||||
if vix is None:
|
||||
return None
|
||||
return _clamp((vix - 15.0) / 15.0 * 100.0)
|
||||
return _clamp(_interpolate(vix, P5_VIX_ANCHORS))
|
||||
|
||||
|
||||
def breadth_level_score(pct_above_200: float | None) -> float | None:
|
||||
@@ -513,7 +573,7 @@ def _overlay_timing(
|
||||
|
||||
|
||||
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
||||
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
|
||||
"""Point-in-time qualitative overlay. Never feeds State or Warning since v3.
|
||||
|
||||
The effective-date gate stays even though nothing is scored from this: the
|
||||
400-session rebuild replays historical dates, and stamping today's LLM read
|
||||
@@ -597,7 +657,7 @@ def _compute_index(
|
||||
divergence_series: Series | None = None,
|
||||
breadth_counts: dict[date, int] | None = None,
|
||||
) -> dict:
|
||||
"""Compute the complete v2 State/Warning snapshot as of one trading date."""
|
||||
"""Compute the complete State/Warning snapshot as of one trading date."""
|
||||
tickers = config["tickers"]
|
||||
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
||||
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
|
||||
@@ -772,7 +832,7 @@ async def get_regime_config(db: AsyncSession) -> dict:
|
||||
if stored.get("fundamental_staleness_days") is not None:
|
||||
cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"])
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
logger.warning("Corrupt %s; using v2 defaults", KEY_CONFIG)
|
||||
logger.warning("Corrupt %s; using defaults", KEY_CONFIG)
|
||||
return cfg
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,322 +1,6 @@
|
||||
# AI/Tech Risk Monitor v3 methodology
|
||||
# Moved
|
||||
|
||||
Named "Regime Monitor" until 2026-08-07; the filename, the `regime_monitor` job
|
||||
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 methodology doc now lives at [regime-monitor-v4.md](regime-monitor-v4.md).
|
||||
|
||||
The AI/Tech Risk Monitor is an observational 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").
|
||||
|
||||
`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 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.**
|
||||
v3's text is in git history (`git log --follow docs/research/regime-monitor-v4.md`).
|
||||
This stub exists because commit messages up to 2026-08-08 cite the old path.
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
|
||||
**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 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.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.
|
||||
|
||||
**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 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 1–3 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 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.
|
||||
const DEFAULT_STATE_DIVIDER = 50;
|
||||
const DEFAULT_WARNING_DIVIDER = 40;
|
||||
|
||||
@@ -562,7 +562,7 @@ export interface RegimeMonitor {
|
||||
}
|
||||
|
||||
export interface RegimeFundamentals {
|
||||
methodology: 'v3';
|
||||
methodology: 'v4';
|
||||
f1_score: number | null;
|
||||
f3_score: number | null;
|
||||
locked: boolean;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
# Regime Monitor v4 calibration
|
||||
|
||||
Generated 2026-08-08T23:16:44 at `43ee619`, 2024-12-05 → 2026-07-24.
|
||||
|
||||
Source hashes (sha256, first 16):
|
||||
|
||||
- `app/services/regime_monitor_service.py` — `3b307e3c2045f35b`
|
||||
- `app/services/breadth_service.py` — `b9ceb93d68d8f01b`
|
||||
- `scripts/run_regime_monitor_calibration.py` — `9bc925256cc6f567`
|
||||
|
||||
## Hard gates
|
||||
|
||||
| gate | expected | measured | |
|
||||
|---|---|---|---|
|
||||
| symbols_fetched | 33 | 33 | ok |
|
||||
| per_symbol_warmup_252_bars | all | 33 | ok |
|
||||
| per_symbol_reaches_last_session | 2026-07-24 | 33 | ok |
|
||||
| breadth_counts_full_basket | 30 | 408/408 sessions | ok |
|
||||
| sessions_scored | 408 | 408 | ok |
|
||||
| last_scored_date | 2026-07-24 | 2026-07-24 | ok |
|
||||
| w1_available_every_session | 408 | 408 | ok |
|
||||
| state_coverage_100_every_row | 0 | 0 | ok |
|
||||
| no_stale_inputs | 0 | 0 | ok |
|
||||
| first_scored_date | 2024-12-05 | 2024-12-05 | ok |
|
||||
| state_v4_le_v3_every_row | 0 | 0 | ok |
|
||||
|
||||
## Distributions
|
||||
|
||||
| variant | avg | median | p80 | p90 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| v2_reconstruction | 22.68 | 16.15 | 35.1 | 65.63 | 91.2 |
|
||||
| v2_reconstruction_oas400 | 26.54 | 18.65 | 42.52 | 81.3 | 100.0 |
|
||||
| v3 | 18.13 | 9.1 | 31.36 | 65.0 | 87.4 |
|
||||
| v4 | 14.78 | 8.35 | 21.7 | 43.63 | 83.5 |
|
||||
| v4-vix-only | 16.64 | 8.35 | 28.6 | 61.59 | 86.6 |
|
||||
| v4-p1-only | 16.28 | 9.1 | 25.44 | 45.63 | 84.0 |
|
||||
| v4-vix-b | 15.24 | 8.55 | 22.62 | 44.33 | 83.6 |
|
||||
| v4-p1-capped | 14.73 | 8.35 | 21.7 | 43.63 | 80.1 |
|
||||
|
||||
## Saturation census (sessions pegged at 100)
|
||||
|
||||
| variant | P1 | P2 | P3 | V1 |
|
||||
|---|---|---|---|---|
|
||||
| v2_reconstruction | 46 | 0 | 39 | 14 |
|
||||
| v2_reconstruction_oas400 | 46 | 0 | 39 | 14 |
|
||||
| v3 | 46 | 0 | 0 | 14 |
|
||||
| v4 | 0 | 0 | 0 | 0 |
|
||||
| v4-vix-only | 46 | 0 | 0 | 0 |
|
||||
| v4-p1-only | 0 | 0 | 0 | 14 |
|
||||
| v4-vix-b | 0 | 0 | 0 | 0 |
|
||||
| v4-p1-capped | 0 | 0 | 0 | 0 |
|
||||
|
||||
## Reproduction gates — v2_reconstruction
|
||||
|
||||
| figure | published | measured | |
|
||||
|---|---|---|---|
|
||||
| v2_state_avg | 22.6 | 22.68 | ok |
|
||||
| v2_state_p80 | 35.1 | 35.1 | ok |
|
||||
| v2_state_max | 91.2 | 91.2 | ok |
|
||||
| v2_p3_pegged | 39 | 39 | ok |
|
||||
| w1_live_sessions | 108 | 108 | ok |
|
||||
|
||||
## Reproduction gates — v3
|
||||
|
||||
| figure | published | measured | |
|
||||
|---|---|---|---|
|
||||
| v3_state_max | 87.4 | 87.4 | ok |
|
||||
|
||||
## v4 band-share grid (watch 20 / elevated 50)
|
||||
|
||||
| breaking | stable | watch | elevated | breaking |
|
||||
|---|---|---|---|---|
|
||||
| 60 | 78.9 | 13.0 | 2.9 | 5.1 |
|
||||
| 65 | 78.9 | 13.0 | 4.7 | 3.4 |
|
||||
| 70 | 78.9 | 13.0 | 6.9 | 1.2 |
|
||||
|
||||
## Scenarios (pillar arithmetic, explicit sensor scores)
|
||||
|
||||
| scenario | price | breadth | C1 | V1 | State |
|
||||
|---|---|---|---|---|---|
|
||||
| S1 ordinary tape | 7.5 | 0.0 | 0.0 | 4.0 | **3.6** |
|
||||
| S2 10% correction, calm credit | 31.25 | 62.5 | 0.0 | 34.4 | **33.28** |
|
||||
| S3a 2022-style, calm credit, no death cross | 90.83 | 100.0 | 0.0 | 60.0 | **70.33** |
|
||||
| S3b 2022-style, calm credit, death cross | 100.0 | 100.0 | 0.0 | 60.0 | **74.0** |
|
||||
| S4 credit event on top | 100.0 | 100.0 | 75.0 | 86.67 | **93.0** |
|
||||
| S5 March 2020, everything pegged | 100.0 | 100.0 | 100.0 | 100.0 | **100.0** |
|
||||
|
||||
Recommendation: `{'state_bands_candidate': [20.0, 50.0, 65.0], 'provisional': False, 'note': 'confirm against band_grid + scenarios before shipping'}`
|
||||
@@ -0,0 +1,877 @@
|
||||
"""Offline replay of the AI/Tech Risk Monitor, for calibrating a methodology cut.
|
||||
|
||||
Reproduces the State/Warning series session by session from the same inputs the
|
||||
live job uses -- Alpaca for prices, FRED for VIX and HY OAS -- with no database,
|
||||
so a sensor change can be measured against real history before it ships.
|
||||
|
||||
v3 was calibrated this way ad-hoc and the harness was never committed, which is
|
||||
why its published numbers cannot be re-derived today. This is that harness.
|
||||
|
||||
**It never reimplements an unchanged live sensor.** ``_compute_index``,
|
||||
``_score_pillars``, breadth, divergence, P2, P4 and the Warning sensors are
|
||||
imported and called. Only *candidate* formulas (proposed for v4) and *retired*
|
||||
ones (v2, no longer in the codebase) are defined here and patched onto the
|
||||
service for the duration of a variant. Once a candidate ships, delete it here and
|
||||
import the shipped function instead, or the two will drift.
|
||||
|
||||
The script refuses to emit a band recommendation unless every hard gate passes.
|
||||
That is deliberate: it must be structurally impossible to read a calibration
|
||||
result out of a run whose pipeline did not validate.
|
||||
|
||||
Research branch only. Example:
|
||||
|
||||
.\\.venv\\Scripts\\python.exe scripts\\run_regime_monitor_calibration.py ^
|
||||
--end 2026-07-24 --sessions 408 --methodology v3,v4 ^
|
||||
--cache-dir .calib-cache
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from copy import deepcopy
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.config import settings # noqa: E402
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider # noqa: E402
|
||||
from app.services import breadth_service # noqa: E402
|
||||
from app.services import regime_monitor_service as rms # noqa: E402
|
||||
|
||||
# Published v3/v2 figures from docs/research/regime-monitor-v3.md. The v3 pair is
|
||||
# DERIVED there (22.6 - 0.4; 91.2 - 3.8), not measured, so its tolerance is loose
|
||||
# on purpose -- anything tighter would be false precision.
|
||||
PUBLISHED = {
|
||||
"window_end": "2026-07-24",
|
||||
# The session count alone is tautological -- the harness slices the tail of
|
||||
# leader_series, so it can only ever equal what was asked for. The start date
|
||||
# is what actually validates the calendar.
|
||||
"window_first": "2024-12-05",
|
||||
"sessions": 408,
|
||||
"w1_live_sessions": 108,
|
||||
"v2_state_avg": 22.6,
|
||||
"v2_state_p80": 35.1,
|
||||
"v2_state_max": 91.2,
|
||||
"v2_p3_pegged": 39,
|
||||
# No v3 average is published: the doc's "-0.4" is measured against
|
||||
# v3-with-the-percentile-leg, not against v2, so only the max is checkable.
|
||||
"v3_state_max": 87.4,
|
||||
}
|
||||
|
||||
# Every symbol comes from one source on one split-adjustment basis. Mixing a
|
||||
# sqlite snapshot for the basket with Alpaca for the leaders would splice two
|
||||
# adjustment bases mid-200-DMA for any symbol that split in between.
|
||||
LEADER, CONFIRM, MARKET = "SMH", "QQQ", "SPY"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Candidate formulas (proposed for v4) -- patched in, never shipped from here
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
P5_VIX_ANCHORS_A = ((15.0, 0.0), (20.0, 20.0), (25.0, 38.0), (30.0, 55.0), (40.0, 80.0), (55.0, 100.0))
|
||||
P5_VIX_ANCHORS_B = ((15.0, 0.0), (20.0, 25.0), (25.0, 45.0), (30.0, 65.0), (40.0, 85.0), (55.0, 100.0))
|
||||
P1_TREND_BREAK_ANCHORS = ((0.0, 20.0), (3.0, 35.0), (8.0, 55.0), (15.0, 75.0), (25.0, 100.0))
|
||||
|
||||
|
||||
def _candidate_under_200(closes: list[float], anchors=P1_TREND_BREAK_ANCHORS) -> float | None:
|
||||
"""Graduated trend break: 0 above the 200-DMA, else scaled by depth below it.
|
||||
|
||||
The live version returns a bare 0/100, which pins the price pillar's max()
|
||||
at 100 through any real selloff and stops P3's ladder resolving. The step at
|
||||
the crossing (0 -> 20) is kept deliberately: the break itself is a genuine
|
||||
binary event and deserves a floor; only the depth past it is graduated.
|
||||
"""
|
||||
sma200 = rms._sma(closes, 200)
|
||||
if sma200 is None or sma200 <= 0:
|
||||
return None
|
||||
pct_below = (sma200 - closes[-1]) / sma200 * 100.0
|
||||
if pct_below <= 0:
|
||||
return 0.0
|
||||
return rms._clamp(rms._interpolate(pct_below, anchors))
|
||||
|
||||
|
||||
def _candidate_p1(anchors=P1_TREND_BREAK_ANCHORS) -> Callable:
|
||||
def p1_trend_break(smh, qqq, leader_weight: float = 2.0):
|
||||
return rms._blend(
|
||||
_candidate_under_200(smh, anchors), _candidate_under_200(qqq, anchors), leader_weight
|
||||
)
|
||||
|
||||
return p1_trend_break
|
||||
|
||||
|
||||
def _candidate_p5(anchors) -> Callable:
|
||||
def p5_volatility(vix: float | None) -> float | None:
|
||||
if vix is None:
|
||||
return None
|
||||
return rms._clamp(rms._interpolate(vix, anchors))
|
||||
|
||||
return p5_volatility
|
||||
|
||||
|
||||
def _capped(fn: Callable, cap: float) -> Callable:
|
||||
"""P1_SCORE_CAP fallback: cap the sensor score after the blend, before max()."""
|
||||
|
||||
def wrapped(smh, qqq, leader_weight: float = 2.0):
|
||||
value = fn(smh, qqq, leader_weight)
|
||||
return None if value is None else min(value, cap)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:
|
||||
if len(closes) < 30:
|
||||
return None
|
||||
peak = max(closes[-252:])
|
||||
if peak <= 0:
|
||||
return None
|
||||
return rms._clamp((peak - closes[-1]) / peak * 100.0 * 5.0)
|
||||
|
||||
|
||||
def _v2_p3_drawdown(smh, qqq, leader_weight: float = 2.0) -> float | None:
|
||||
"""v2 took max() across the legs, so the more volatile leader always won."""
|
||||
vals = [v for v in (_v2_drawdown(smh), _v2_drawdown(qqq)) if v is not None]
|
||||
return max(vals) if vals else None
|
||||
|
||||
|
||||
def _v2_divergence_series(breadth, benchmark_closes, lookback: int = 20):
|
||||
"""v2's hard price gate: the sensor ZEROED during any decline.
|
||||
|
||||
v3 replaced this with a taper, which is why v2 shows W1 nonzero on only 108
|
||||
of 408 sessions while v3 shows it nonzero far more often. Reconstructing it
|
||||
is the only way to check that published figure.
|
||||
"""
|
||||
bench = {d: c for d, c in benchmark_closes}
|
||||
common = sorted(d for d in bench if d in breadth)
|
||||
out: dict[date, float] = {}
|
||||
for i in range(lookback, len(common)):
|
||||
d, d0 = common[i], common[i - lookback]
|
||||
if bench[d0] <= 0:
|
||||
continue
|
||||
price_ret = (bench[d] / bench[d0] - 1.0) * 100.0
|
||||
deterioration = max(0.0, -(breadth[d] - breadth[d0]))
|
||||
score = deterioration * 5.0 if price_ret >= 0 else 0.0
|
||||
out[d] = max(0.0, min(100.0, round(score, 2)))
|
||||
return out
|
||||
|
||||
|
||||
def _v2_f2_credit_spreads(oas_values: list[float]) -> float | None:
|
||||
"""70% named anchors + 30% upper-tail percentile over whatever window it got."""
|
||||
if not oas_values:
|
||||
return None
|
||||
latest = oas_values[-1]
|
||||
absolute = rms._oas_absolute_score(latest)
|
||||
if len(oas_values) < 30:
|
||||
return round(absolute, 2)
|
||||
less = sum(1 for v in oas_values if v < latest)
|
||||
equal = sum(1 for v in oas_values if v == latest)
|
||||
percentile = (less + 0.5 * equal) / len(oas_values) * 100.0
|
||||
relative = rms._clamp((percentile - 50.0) / 45.0 * 100.0)
|
||||
return round(absolute * 0.7 + relative * 0.3, 2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VARIANTS: dict[str, dict[str, Callable]] = {
|
||||
# Retired since the v4 cutover -- "nothing patched" is now v4, so v3 has to
|
||||
# be reconstructed like v2 to stay comparable.
|
||||
"v3": {
|
||||
"p1_trend_break": _v3_p1_trend_break,
|
||||
"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": {
|
||||
"p1_trend_break": _candidate_p1(),
|
||||
"p5_volatility": _candidate_p5(P5_VIX_ANCHORS_B),
|
||||
},
|
||||
"v4-p1-capped": {
|
||||
"p1_trend_break": _capped(_candidate_p1(), 50.0),
|
||||
"p5_volatility": _candidate_p5(P5_VIX_ANCHORS_A),
|
||||
},
|
||||
"v4-vix-only": {"p1_trend_break": _v3_p1_trend_break},
|
||||
"v4-p1-only": {"p5_volatility": _v3_p5_volatility},
|
||||
# (4) v2 as production actually fetched it: a 400-calendar-day OAS source,
|
||||
# which left the oldest rows with no credit at all. Truncating the SERIES is
|
||||
# the only faithful simulation -- patching the per-session window is not,
|
||||
# because the data was simply absent.
|
||||
"v2_reconstruction_oas400": {
|
||||
"p1_trend_break": _v3_p1_trend_break,
|
||||
"p5_volatility": _v3_p5_volatility,
|
||||
"p3_drawdown": _v2_p3_drawdown,
|
||||
"f2_credit_spreads": _v2_f2_credit_spreads,
|
||||
"HY_OAS_WINDOW_DAYS": 3653,
|
||||
},
|
||||
# v2 State sensors + the v2 divergence gate that feeds W1. The v2 *Warning
|
||||
# composition* (F1/F3 fundamentals, 20 of 100 points) is NOT reconstructed,
|
||||
# so only State statistics and the W1 census are comparable to the published
|
||||
# v2 figures -- not the Warning score.
|
||||
"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,
|
||||
"f2_credit_spreads": _v2_f2_credit_spreads,
|
||||
# v2 sliced HY_OAS_REFERENCE_YEARS = 10.0 per session. The percentile leg
|
||||
# ranks the current spread against that window, so replaying it against
|
||||
# a 700-day slice gives systematically different mid-distribution scores.
|
||||
"HY_OAS_WINDOW_DAYS": 3653,
|
||||
},
|
||||
}
|
||||
|
||||
# Variants needing the retired divergence formula rather than the live one.
|
||||
V2_DIVERGENCE_VARIANTS = {"v2_reconstruction", "v2_reconstruction_oas400"}
|
||||
|
||||
# Variants whose OAS *source series* is truncated before replay, in calendar days.
|
||||
OAS_SOURCE_TRUNCATION = {"v2_reconstruction_oas400": 400}
|
||||
|
||||
# A v4 recommendation is meaningless without both of these: the row-wise
|
||||
# state_v4 <= state_v3 invariant needs them, and it is a hard gate.
|
||||
# v2_reconstruction is required too: it carries every published figure the
|
||||
# reproduction rests on (avg/p80/max/P3-pegged/W1-live). Without it a run could
|
||||
# emit a confident recommendation having checked nothing against v2 at all,
|
||||
# while the methodology doc claims v2 and v3 are reproduced first.
|
||||
REQUIRED_VARIANTS = ("v2_reconstruction", "v3", "v4")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def patched(overrides: dict[str, Callable]) -> Iterator[None]:
|
||||
"""Swap functions on the service module, then restore exactly."""
|
||||
original = {name: getattr(rms, name) for name in overrides}
|
||||
try:
|
||||
for name, fn in overrides.items():
|
||||
setattr(rms, name, fn)
|
||||
yield
|
||||
finally:
|
||||
for name, fn in original.items():
|
||||
setattr(rms, name, fn)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _basket(config: dict) -> list[str]:
|
||||
return list(config["breadth_basket"])
|
||||
|
||||
|
||||
def _all_symbols(config: dict) -> list[str]:
|
||||
return list(dict.fromkeys(_basket(config) + [LEADER, CONFIRM, MARKET]))
|
||||
|
||||
|
||||
async def _load_prices(
|
||||
symbols: list[str], start: date, end: date, cache_dir: Path | None, quiet: bool
|
||||
) -> dict[str, list[tuple[date, float]]]:
|
||||
cache = None
|
||||
if cache_dir:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache = cache_dir / f"prices-{start}-{end}.json"
|
||||
if cache.exists():
|
||||
raw = json.loads(cache.read_text(encoding="utf-8"))
|
||||
if set(raw) >= set(symbols):
|
||||
if not quiet:
|
||||
print(f"prices: cache hit ({len(raw)} symbols)", flush=True)
|
||||
return {
|
||||
s: [(date.fromisoformat(d), float(c)) for d, c in raw[s]] for s in symbols
|
||||
}
|
||||
|
||||
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
|
||||
out: dict[str, list[tuple[date, float]]] = {}
|
||||
for index, symbol in enumerate(symbols, 1):
|
||||
bars = await provider.fetch_ohlcv(symbol, start, end)
|
||||
out[symbol] = sorted((b.date, float(b.close)) for b in bars)
|
||||
if not quiet:
|
||||
print(f" [{index}/{len(symbols)}] {symbol}: {len(out[symbol])} bars", flush=True)
|
||||
if cache:
|
||||
cache.write_text(
|
||||
json.dumps({s: [[d.isoformat(), c] for d, c in v] for s, v in out.items()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def _load_fred(series_id: str, start: date, end: date, cache_dir: Path | None):
|
||||
cache = cache_dir / f"{series_id}-{start}-{end}.json" if cache_dir else None
|
||||
if cache and cache.exists():
|
||||
raw = json.loads(cache.read_text(encoding="utf-8"))
|
||||
return [(date.fromisoformat(d), float(v)) for d, v in raw]
|
||||
series = await rms._fetch_fred_series(series_id, start, end)
|
||||
if cache and series:
|
||||
cache.write_text(
|
||||
json.dumps([[d.isoformat(), v] for d, v in series]), encoding="utf-8"
|
||||
)
|
||||
return series
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _replay(
|
||||
variant: str,
|
||||
prices: dict[str, list[tuple[date, float]]],
|
||||
vix, oas, config: dict, sessions: list[date],
|
||||
breadth_series, divergence_by_variant: dict[str, Any], breadth_counts,
|
||||
) -> list[dict]:
|
||||
# Divergence is computed outside _compute_index, so the retired v2 gate has
|
||||
# to be selected here rather than patched onto the module.
|
||||
divergence_series = divergence_by_variant[
|
||||
"v2" if variant in V2_DIVERGENCE_VARIANTS else "live"
|
||||
]
|
||||
truncate_days = OAS_SOURCE_TRUNCATION.get(variant)
|
||||
if truncate_days is not None and oas:
|
||||
cutoff = max(d for d, _ in oas) - timedelta(days=truncate_days)
|
||||
oas = [(d, v) for d, v in oas if d >= cutoff]
|
||||
rows: list[dict] = []
|
||||
with patched(VARIANTS[variant]):
|
||||
for as_of in sessions:
|
||||
rows.append(
|
||||
rms._compute_index(
|
||||
prices, vix, oas, {}, deepcopy(config), as_of,
|
||||
breadth_series, divergence_series, breadth_counts,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _percentile(values: list[float], pct: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
k = (len(ordered) - 1) * pct / 100.0
|
||||
lo, hi = math.floor(k), math.ceil(k)
|
||||
if lo == hi:
|
||||
return ordered[int(k)]
|
||||
return ordered[lo] + (ordered[hi] - ordered[lo]) * (k - lo)
|
||||
|
||||
|
||||
def _sensor_score(row: dict, pillar_id: str, sensor_id: str) -> float | None:
|
||||
"""Search both axes: W1/W2/W3 live under ``warning``, P*/B1/C1/V1 under ``state``."""
|
||||
for axis in ("state", "warning"):
|
||||
for pillar in row[axis]["pillars"]:
|
||||
if pillar["id"] == pillar_id:
|
||||
for sensor in pillar["sensors"]:
|
||||
if sensor["id"] == sensor_id:
|
||||
return sensor["score"]
|
||||
return None
|
||||
|
||||
|
||||
def _band_shares(scores: list[float], bands: tuple[float, float, float]) -> dict[str, float]:
|
||||
if not scores:
|
||||
return {}
|
||||
counts = {"stable": 0, "watch": 0, "elevated": 0, "breaking": 0}
|
||||
for score in scores:
|
||||
counts[rms.band_for(score, bands)] += 1 # bands passed explicitly -- see module docstring
|
||||
return {k: round(v / len(scores) * 100.0, 1) for k, v in counts.items()}
|
||||
|
||||
|
||||
def _stats(rows: list[dict], label: str) -> dict:
|
||||
states = [r["state"]["score"] for r in rows if r["state"]["score"] is not None]
|
||||
warnings = [r["warning"]["score"] for r in rows if r["warning"]["score"] is not None]
|
||||
|
||||
def pegged(pillar: str, sensor: str) -> int:
|
||||
return sum(1 for r in rows if (_sensor_score(r, pillar, sensor) or 0) >= 100.0)
|
||||
|
||||
argmax_sole, argmax_tied, ties = {"P1": 0, "P2": 0, "P3": 0}, {"P1": 0, "P2": 0, "P3": 0}, 0
|
||||
# The P1_SCORE_CAP rule is "sole argmax on >80% of sessions with State >= 40",
|
||||
# so the all-session count does not evaluate it. Track the conditional
|
||||
# population separately rather than deciding off the wrong denominator.
|
||||
stressed_sole, stressed_total = {"P1": 0, "P2": 0, "P3": 0}, 0
|
||||
for row in rows:
|
||||
legs = {s: _sensor_score(row, "price", s) for s in ("P1", "P2", "P3")}
|
||||
live = {k: v for k, v in legs.items() if v is not None}
|
||||
if not live:
|
||||
continue
|
||||
top = max(live.values())
|
||||
winners = [k for k, v in live.items() if v == top]
|
||||
if len(winners) > 1:
|
||||
ties += 1
|
||||
for w in winners:
|
||||
argmax_tied[w] += 1
|
||||
if len(winners) == 1:
|
||||
argmax_sole[winners[0]] += 1
|
||||
if (row["state"]["score"] or 0) >= 40.0:
|
||||
stressed_total += 1
|
||||
if len(winners) == 1:
|
||||
stressed_sole[winners[0]] += 1
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"sessions": len(rows),
|
||||
"state": {
|
||||
"avg": round(statistics.fmean(states), 2) if states else None,
|
||||
"median": round(statistics.median(states), 2) if states else None,
|
||||
"p80": round(_percentile(states, 80), 2) if states else None,
|
||||
"p90": round(_percentile(states, 90), 2) if states else None,
|
||||
"max": round(max(states), 2) if states else None,
|
||||
"scored": len(states),
|
||||
},
|
||||
"warning_avg": round(statistics.fmean(warnings), 2) if warnings else None,
|
||||
"saturation_census": {
|
||||
"p3_pegged": pegged("price", "P3"),
|
||||
"p2_pegged": pegged("price", "P2"),
|
||||
"v1_pegged": pegged("volatility", "V1"),
|
||||
"p1_pegged": pegged("price", "P1"),
|
||||
},
|
||||
"price_argmax_sole": argmax_sole,
|
||||
"price_argmax_tie_inclusive": argmax_tied,
|
||||
"price_argmax_ties": ties,
|
||||
"price_argmax_when_state_ge_40": {
|
||||
"sessions": stressed_total,
|
||||
"sole": stressed_sole,
|
||||
"p1_sole_share_pct": round(stressed_sole["P1"] / stressed_total * 100.0, 1)
|
||||
if stressed_total else None,
|
||||
"cap_rule": "P1_SCORE_CAP warranted if p1_sole_share_pct > 80",
|
||||
},
|
||||
"w1_nonzero_sessions": sum(
|
||||
1 for r in rows if (_sensor_score(r, "breadth_divergence", "W1") or 0) > 0
|
||||
),
|
||||
"band_shares_current": _band_shares(states, rms.STATE_BANDS),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _completeness_gates(
|
||||
prices: dict, symbols: list[str], sessions: list[date], breadth_counts: dict, basket_size: int
|
||||
) -> list[dict]:
|
||||
"""Without these, every gate below can pass on partial data.
|
||||
|
||||
_breadth_with_counts publishes on min_tickers=20, so 20 of 30 basket names
|
||||
still yields 100% State coverage and a plausible W1 count.
|
||||
"""
|
||||
first, last = sessions[0], sessions[-1]
|
||||
missing = [s for s in symbols if not prices.get(s)]
|
||||
thin = [
|
||||
s for s in symbols
|
||||
if len([d for d, _ in prices.get(s, []) if d < first]) < 252
|
||||
]
|
||||
truncated = [s for s in symbols if not prices.get(s) or prices[s][-1][0] < last]
|
||||
short_basket = sorted(
|
||||
d.isoformat() for d in sessions if breadth_counts.get(d, 0) != basket_size
|
||||
)
|
||||
return [
|
||||
{"gate": "symbols_fetched", "expected": len(symbols),
|
||||
"measured": len(symbols) - len(missing), "passed": not missing, "detail": missing},
|
||||
{"gate": "per_symbol_warmup_252_bars", "expected": "all",
|
||||
"measured": len(symbols) - len(thin), "passed": not thin, "detail": thin},
|
||||
{"gate": "per_symbol_reaches_last_session", "expected": last.isoformat(),
|
||||
"measured": len(symbols) - len(truncated), "passed": not truncated, "detail": truncated},
|
||||
{"gate": "breadth_counts_full_basket", "expected": basket_size,
|
||||
"measured": f"{len(sessions) - len(short_basket)}/{len(sessions)} sessions",
|
||||
"passed": not short_basket, "detail": short_basket[:20]},
|
||||
]
|
||||
|
||||
|
||||
def _pipeline_gates(rows: list[dict], sessions: list[date], expected_first: str) -> list[dict]:
|
||||
coverage_bad = [
|
||||
r["date"] for r in rows if (r["state"]["coverage"] or 0) < 100.0
|
||||
]
|
||||
w1_available = sum(
|
||||
1 for r in rows if _sensor_score(r, "breadth_divergence", "W1") is not None
|
||||
)
|
||||
stale = [r["date"] for r in rows if r["data_quality"]["stale_inputs"]]
|
||||
gates = [
|
||||
{"gate": "sessions_scored", "expected": PUBLISHED["sessions"],
|
||||
"measured": len(rows), "passed": len(rows) == PUBLISHED["sessions"]},
|
||||
{"gate": "last_scored_date", "expected": PUBLISHED["window_end"],
|
||||
"measured": rows[-1]["date"], "passed": rows[-1]["date"] == PUBLISHED["window_end"]},
|
||||
# Availability, not the published "W1 live 108" -- that figure counts
|
||||
# NONZERO sessions under v2's hard price gate and is checked there.
|
||||
{"gate": "w1_available_every_session", "expected": len(rows),
|
||||
"measured": w1_available, "passed": w1_available == len(rows)},
|
||||
{"gate": "state_coverage_100_every_row", "expected": 0,
|
||||
"measured": len(coverage_bad), "passed": not coverage_bad, "detail": coverage_bad[:20]},
|
||||
{"gate": "no_stale_inputs", "expected": 0,
|
||||
"measured": len(stale), "passed": not stale, "detail": stale[:20]},
|
||||
]
|
||||
# Unconditional: sessions_scored is tautological when the harness slices the
|
||||
# tail of leader_series, so the start date is the only real calendar check.
|
||||
# An optional gate is not a gate.
|
||||
gates.append({
|
||||
"gate": "first_scored_date", "expected": expected_first,
|
||||
"measured": rows[0]["date"], "passed": rows[0]["date"] == expected_first,
|
||||
})
|
||||
return gates
|
||||
|
||||
|
||||
def _invariant_gate(v3_rows: list[dict], v4_rows: list[dict]) -> dict:
|
||||
"""state_v4 <= state_v3 on every aligned row.
|
||||
|
||||
Provable, not heuristic: graduated P1 never exceeds binary P1, anchored VIX
|
||||
never exceeds (vix-15)/15*100, max() is monotone in its arguments, and no
|
||||
other State sensor or weight changes. A violation means the harness is
|
||||
mis-wired, not that the calibration is interesting.
|
||||
"""
|
||||
violations = []
|
||||
for a, b in zip(v3_rows, v4_rows):
|
||||
assert a["date"] == b["date"], "row misalignment"
|
||||
s3, s4 = a["state"]["score"], b["state"]["score"]
|
||||
if s3 is not None and s4 is not None and s4 > s3 + 1e-9:
|
||||
violations.append({"date": a["date"], "v3": s3, "v4": s4})
|
||||
return {
|
||||
"gate": "state_v4_le_v3_every_row", "expected": 0,
|
||||
"measured": len(violations), "passed": not violations, "detail": violations[:20],
|
||||
}
|
||||
|
||||
|
||||
def _soft_gates(stats: dict, variant: str) -> list[dict]:
|
||||
if variant == "v3":
|
||||
# The doc states no v3 average: its "-0.4" is measured against
|
||||
# v3-with-the-percentile-leg, not against v2. Only the max is checkable.
|
||||
pairs = [("v3_state_max", stats["state"]["max"], 0.5)]
|
||||
else:
|
||||
pairs = [("v2_state_avg", stats["state"]["avg"], 0.3),
|
||||
("v2_state_p80", stats["state"]["p80"], 0.5),
|
||||
("v2_state_max", stats["state"]["max"], 0.5),
|
||||
("v2_p3_pegged", stats["saturation_census"]["p3_pegged"], 0),
|
||||
("w1_live_sessions", stats["w1_nonzero_sessions"], 0)]
|
||||
out = []
|
||||
for key, measured, tol in pairs:
|
||||
expected = PUBLISHED[key]
|
||||
ok = measured is not None and abs(measured - expected) <= tol
|
||||
out.append({"gate": key, "expected": expected, "measured": measured,
|
||||
"tolerance": tol, "passed": ok})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenarios -- pillar arithmetic, stated as explicit sensor scores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _scenarios(vix_anchors, p1_anchors) -> list[dict]:
|
||||
"""The meaning anchors for the band choice, machine-checked rather than prose.
|
||||
|
||||
Stated as explicit sensor scores because drawdown + VIX + OAS does not
|
||||
determine State: the price pillar is max(P1, P2, P3) and P2 is set by the
|
||||
50/200-DMA gap, which no drawdown figure implies.
|
||||
"""
|
||||
def state(p1, p2, p3, breadth, c1, v1):
|
||||
price = max(p1, p2, p3)
|
||||
return round((price * 40 + breadth * 25 + c1 * 20 + v1 * 15) / 100, 2)
|
||||
|
||||
def p1_at(pct_below):
|
||||
return round(rms._interpolate(pct_below, p1_anchors), 2) if pct_below > 0 else 0.0
|
||||
|
||||
def p3_at(dd):
|
||||
return round(rms._interpolate(dd, rms.P3_DRAWDOWN_ANCHORS), 2)
|
||||
|
||||
def v1_at(vix):
|
||||
return round(rms._interpolate(vix, vix_anchors), 2)
|
||||
|
||||
rows = [
|
||||
("S1 ordinary tape", 0.0, 0.0, p3_at(3), rms.breadth_level_score(65), 0.0, v1_at(16)),
|
||||
("S2 10% correction, calm credit", p1_at(2), 0.0, p3_at(10), rms.breadth_level_score(35), 0.0, v1_at(24)),
|
||||
("S3a 2022-style, calm credit, no death cross", p1_at(20), 0.0, p3_at(35), rms.breadth_level_score(8), 0.0, v1_at(32)),
|
||||
("S3b 2022-style, calm credit, death cross", p1_at(20), 100.0, p3_at(35), rms.breadth_level_score(8), 0.0, v1_at(32)),
|
||||
("S4 credit event on top", p1_at(25), 100.0, p3_at(40), rms.breadth_level_score(5), rms.f2_credit_spreads([6.0]), v1_at(45)),
|
||||
("S5 March 2020, everything pegged", 100.0, 100.0, 100.0, 100.0, 100.0, 100.0),
|
||||
]
|
||||
return [
|
||||
{"scenario": name, "P1": p1, "P2": p2, "P3": p3, "price": max(p1, p2, p3),
|
||||
"breadth": br, "C1": c1, "V1": v1, "state": state(p1, p2, p3, br, c1, v1)}
|
||||
for name, p1, p2, p3, br, c1, v1 in rows
|
||||
]
|
||||
|
||||
|
||||
def _markdown(report: dict) -> str:
|
||||
"""Scannable sibling to the JSON. Never written over a curated docs/ file."""
|
||||
lines = ["# Regime Monitor v4 calibration", ""]
|
||||
src = report["source"]
|
||||
dirty = " **(dirty working tree)**" if src.get("git_dirty") else ""
|
||||
lines.append(f"Generated {report['generated_at']} at `{src['git_rev']}`{dirty}, "
|
||||
f"{report['provenance']['scored_range'][0]} → {report['provenance']['scored_range'][1]}.")
|
||||
lines += ["", "Source hashes (sha256, first 16):", ""]
|
||||
for rel, digest in src["source_sha256"].items():
|
||||
lines.append(f"- `{rel}` — `{digest}`")
|
||||
lines += ["", "## Hard gates", "", "| gate | expected | measured | |", "|---|---|---|---|"]
|
||||
for g in report["hard_gates"]:
|
||||
lines.append(f"| {g['gate']} | {g['expected']} | {g['measured']} | {'ok' if g['passed'] else '**FAIL**'} |")
|
||||
lines += ["", "## Distributions", "", "| variant | avg | median | p80 | p90 | max |", "|---|---|---|---|---|---|"]
|
||||
for name, v in report["variants"].items():
|
||||
st = v["state"]
|
||||
lines.append(f"| {name} | {st['avg']} | {st['median']} | {st['p80']} | {st['p90']} | {st['max']} |")
|
||||
lines += ["", "## Saturation census (sessions pegged at 100)", "",
|
||||
"| variant | P1 | P2 | P3 | V1 |", "|---|---|---|---|---|"]
|
||||
for name, v in report["variants"].items():
|
||||
c = v["saturation_census"]
|
||||
lines.append(f"| {name} | {c['p1_pegged']} | {c['p2_pegged']} | {c['p3_pegged']} | {c['v1_pegged']} |")
|
||||
for name, v in report["variants"].items():
|
||||
if v.get("soft_gates"):
|
||||
lines += ["", f"## Reproduction gates — {name}", "",
|
||||
"| figure | published | measured | |", "|---|---|---|---|"]
|
||||
for g in v["soft_gates"]:
|
||||
lines.append(f"| {g['gate']} | {g['expected']} | {g['measured']} | {'ok' if g['passed'] else '**miss**'} |")
|
||||
if "v4" in report["variants"]:
|
||||
lines += ["", "## v4 band-share grid (watch 20 / elevated 50)", "",
|
||||
"| breaking | stable | watch | elevated | breaking |", "|---|---|---|---|---|"]
|
||||
for row in report["variants"]["v4"]["band_grid"]:
|
||||
w, e, b = row["bands"]
|
||||
if w == 20.0 and e == 50.0:
|
||||
sh = row["shares"]
|
||||
lines.append(f"| {b:.0f} | {sh['stable']} | {sh['watch']} | {sh['elevated']} | {sh['breaking']} |")
|
||||
lines += ["", "## Scenarios (pillar arithmetic, explicit sensor scores)", "",
|
||||
"| scenario | price | breadth | C1 | V1 | State |", "|---|---|---|---|---|---|"]
|
||||
for sc in report["scenarios"]["vix_a"]:
|
||||
lines.append(f"| {sc['scenario']} | {sc['price']} | {sc['breadth']} | {sc['C1']} | {sc['V1']} | **{sc['state']}** |")
|
||||
lines += ["", f"Recommendation: `{report['v4_recommendation']}`", ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _band_grid(states: list[float]) -> list[dict]:
|
||||
grid = []
|
||||
for watch in (15.0, 20.0, 25.0):
|
||||
for elevated in (40.0, 50.0):
|
||||
for breaking in (60.0, 65.0, 70.0):
|
||||
grid.append({
|
||||
"bands": [watch, elevated, breaking],
|
||||
"shares": _band_shares(states, (watch, elevated, breaking)),
|
||||
})
|
||||
return grid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--end", default=PUBLISHED["window_end"])
|
||||
p.add_argument("--sessions", type=int, default=PUBLISHED["sessions"])
|
||||
p.add_argument("--history-days", type=int, default=1200, help="matches production _fetch_prices")
|
||||
p.add_argument("--oas-window-days", type=int, default=rms.HY_OAS_WINDOW_DAYS,
|
||||
help="per-session slice for the canonical run")
|
||||
p.add_argument("--oas-fetch-days", type=int, default=int(365.25 * 13),
|
||||
help="fetch range; matches v2's request (ICE truncates to ~3y)")
|
||||
p.add_argument("--methodology", default=",".join(REQUIRED_VARIANTS))
|
||||
p.add_argument("--expected-first-session", default=None,
|
||||
help="assert the first replayed date. Defaults to the published "
|
||||
"window's start; REQUIRED when --end/--sessions are overridden, "
|
||||
"since the count alone is tautological.")
|
||||
p.add_argument("--cache-dir", default=None)
|
||||
p.add_argument("--out", default=None)
|
||||
p.add_argument("--quiet", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _git_rev() -> str:
|
||||
try:
|
||||
return subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _source_state() -> dict:
|
||||
"""Identify the code that produced this run, not just the commit HEAD names.
|
||||
|
||||
A run from a dirty tree is not reproducible by checking out git_rev -- which
|
||||
is exactly how the first v4 artifact was generated, with HEAD still on the
|
||||
harness commit while the v4 sensors lived only in the working tree. The
|
||||
hashes make that visible instead of implied.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
tracked = [
|
||||
"app/services/regime_monitor_service.py",
|
||||
"app/services/breadth_service.py",
|
||||
"scripts/run_regime_monitor_calibration.py",
|
||||
]
|
||||
digests = {}
|
||||
for rel in tracked:
|
||||
path = ROOT / rel
|
||||
digests[rel] = hashlib.sha256(path.read_bytes()).hexdigest()[:16] if path.exists() else None
|
||||
try:
|
||||
dirty = bool(subprocess.run(["git", "status", "--porcelain"], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout.strip())
|
||||
except Exception:
|
||||
dirty = None
|
||||
return {"git_rev": _git_rev(), "git_dirty": dirty, "source_sha256": digests}
|
||||
|
||||
|
||||
async def _main() -> int:
|
||||
args = _parse_args()
|
||||
end = date.fromisoformat(args.end)
|
||||
cache_dir = Path(args.cache_dir) if args.cache_dir else None
|
||||
config = deepcopy(rms.DEFAULT_CONFIG)
|
||||
symbols = _all_symbols(config)
|
||||
variants = [v.strip() for v in args.methodology.split(",") if v.strip()]
|
||||
expected_first = args.expected_first_session
|
||||
if not expected_first:
|
||||
if args.end != PUBLISHED["window_end"] or args.sessions != PUBLISHED["sessions"]:
|
||||
print("--expected-first-session is required when --end or --sessions "
|
||||
"differ from the published window.", file=sys.stderr)
|
||||
return 2
|
||||
expected_first = PUBLISHED["window_first"]
|
||||
unknown = [v for v in variants if v not in VARIANTS]
|
||||
if unknown:
|
||||
print(f"unknown variant(s): {unknown}; known: {sorted(VARIANTS)}", file=sys.stderr)
|
||||
return 2
|
||||
missing = [v for v in REQUIRED_VARIANTS if v not in variants]
|
||||
if missing:
|
||||
print(f"--methodology must include {list(REQUIRED_VARIANTS)}; missing {missing}. "
|
||||
"v2_reconstruction carries the published reproduction figures, and "
|
||||
"v3+v4 are needed for the state_v4 <= state_v3 invariant gate.",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not args.quiet:
|
||||
print(f"fetching {len(symbols)} symbols from Alpaca...", flush=True)
|
||||
prices = await _load_prices(symbols, end - timedelta(days=args.history_days), end, cache_dir, args.quiet)
|
||||
vix = await _load_fred("VIXCLS", end - timedelta(days=args.history_days), end, cache_dir)
|
||||
oas = await _load_fred("BAMLH0A0HYM2", end - timedelta(days=args.oas_fetch_days), end, cache_dir)
|
||||
|
||||
leader = prices.get(LEADER, [])
|
||||
if not leader:
|
||||
print("no leader (SMH) price data — cannot replay", file=sys.stderr)
|
||||
return 2
|
||||
sessions = [d for d, _ in leader if d <= end][-args.sessions:]
|
||||
|
||||
breadth, breadth_counts = breadth_service._breadth_with_counts(
|
||||
{s: prices[s] for s in _basket(config) if prices.get(s)}, window=200, min_tickers=20
|
||||
)
|
||||
# Required glue: _item_asof breaks on the first date > as_of, so unsorted
|
||||
# input silently returns a wrong value rather than erroring.
|
||||
breadth_series = rms._mapping_series(breadth)
|
||||
divergence_by_variant = {
|
||||
"live": rms._mapping_series(breadth_service.compute_divergence_series(breadth, leader)),
|
||||
"v2": rms._mapping_series(_v2_divergence_series(breadth, leader)),
|
||||
}
|
||||
|
||||
if args.oas_window_days != rms.HY_OAS_WINDOW_DAYS:
|
||||
rms.HY_OAS_WINDOW_DAYS = args.oas_window_days
|
||||
|
||||
results: dict[str, Any] = {}
|
||||
rows_by_variant: dict[str, list[dict]] = {}
|
||||
for variant in variants:
|
||||
if not args.quiet:
|
||||
print(f"replaying {variant} over {len(sessions)} sessions...", flush=True)
|
||||
rows = _replay(variant, prices, vix, oas, config, sessions,
|
||||
breadth_series, divergence_by_variant, breadth_counts)
|
||||
rows_by_variant[variant] = rows
|
||||
results[variant] = _stats(rows, variant)
|
||||
results[variant]["soft_gates"] = _soft_gates(results[variant], variant) \
|
||||
if variant in ("v3", "v2_reconstruction") else []
|
||||
states = [r["state"]["score"] for r in rows if r["state"]["score"] is not None]
|
||||
if variant.startswith("v4"):
|
||||
results[variant]["band_grid"] = _band_grid(states)
|
||||
|
||||
canonical = rows_by_variant.get("v3") or next(iter(rows_by_variant.values()))
|
||||
hard_gates = _completeness_gates(prices, symbols, sessions, breadth_counts, len(_basket(config)))
|
||||
hard_gates += _pipeline_gates(canonical, sessions, expected_first)
|
||||
if "v3" in rows_by_variant and "v4" in rows_by_variant:
|
||||
hard_gates.append(_invariant_gate(rows_by_variant["v3"], rows_by_variant["v4"]))
|
||||
|
||||
blocked_by = [g["gate"] for g in hard_gates if not g["passed"]]
|
||||
provisional = any(
|
||||
not g["passed"] for v in results.values() for g in v.get("soft_gates", [])
|
||||
)
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"source": _source_state(),
|
||||
"params": vars(args),
|
||||
"provenance": {
|
||||
"symbols": {s: {"bars": len(prices.get(s, [])),
|
||||
"first": prices[s][0][0].isoformat() if prices.get(s) else None,
|
||||
"last": prices[s][-1][0].isoformat() if prices.get(s) else None}
|
||||
for s in symbols},
|
||||
"vix": {"points": len(vix or []),
|
||||
"first": vix[0][0].isoformat() if vix else None,
|
||||
"last": vix[-1][0].isoformat() if vix else None},
|
||||
"oas": {"points": len(oas or []),
|
||||
"first": oas[0][0].isoformat() if oas else None,
|
||||
"last": oas[-1][0].isoformat() if oas else None},
|
||||
"scored_range": [sessions[0].isoformat(), sessions[-1].isoformat()],
|
||||
},
|
||||
"hard_gates": hard_gates,
|
||||
"blocked_by": blocked_by,
|
||||
"provisional": provisional,
|
||||
"variants": results,
|
||||
"scenarios": {
|
||||
"note": "pillar arithmetic on explicit sensor scores; State weights unchanged",
|
||||
"vix_a": _scenarios(P5_VIX_ANCHORS_A, P1_TREND_BREAK_ANCHORS),
|
||||
},
|
||||
"diagnostics": {
|
||||
"vix_top10": sorted(((d.isoformat(), v) for d, v in (vix or [])),
|
||||
key=lambda x: -x[1])[:10],
|
||||
"candidate_anchors": {
|
||||
"P5_VIX_ANCHORS_A": P5_VIX_ANCHORS_A,
|
||||
"P5_VIX_ANCHORS_B": P5_VIX_ANCHORS_B,
|
||||
"P1_TREND_BREAK_ANCHORS": P1_TREND_BREAK_ANCHORS,
|
||||
},
|
||||
},
|
||||
# Structurally impossible to read a recommendation out of a run whose
|
||||
# pipeline did not validate.
|
||||
"v4_recommendation": None if blocked_by else {
|
||||
"state_bands_candidate": [20.0, 50.0, 65.0],
|
||||
"provisional": provisional,
|
||||
"note": "confirm against band_grid + scenarios before shipping",
|
||||
},
|
||||
}
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out = Path(args.out) if args.out else ROOT / "reports" / f"regime-monitor-v4-calibration-{stamp}.json"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = out.with_suffix(out.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(out)
|
||||
md = out.with_suffix(".md")
|
||||
md_tmp = md.with_suffix(".md.tmp")
|
||||
md_tmp.write_text(_markdown(report), encoding="utf-8")
|
||||
md_tmp.replace(md)
|
||||
if not args.quiet:
|
||||
print(f"wrote {out}", flush=True)
|
||||
for gate in hard_gates:
|
||||
mark = "ok " if gate["passed"] else "FAIL"
|
||||
print(f" [{mark}] {gate['gate']}: expected {gate['expected']}, got {gate['measured']}")
|
||||
if blocked_by:
|
||||
print(f"HARD GATES FAILED: {blocked_by} — no v4 recommendation emitted", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(_main()))
|
||||
@@ -0,0 +1,183 @@
|
||||
"""The calibration harness's pure helpers. No network, no data.
|
||||
|
||||
The harness's whole value is that its numbers can be trusted, so the parts that
|
||||
decide whether a run is trustworthy — the variant patching and the gate
|
||||
evaluation — are worth pinning even though the script is research-only.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import regime_monitor_service as rms
|
||||
|
||||
_SPEC = importlib.util.spec_from_file_location(
|
||||
"regime_calibration",
|
||||
Path(__file__).resolve().parents[2] / "scripts" / "run_regime_monitor_calibration.py",
|
||||
)
|
||||
calib = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(calib)
|
||||
|
||||
|
||||
class TestVariantPatching:
|
||||
def test_patching_restores_module_state_exactly(self):
|
||||
"""A leaked patch would silently contaminate every later variant."""
|
||||
before = {
|
||||
name: getattr(rms, name)
|
||||
for name in ("p1_trend_break", "p5_volatility", "p3_drawdown",
|
||||
"f2_credit_spreads", "HY_OAS_WINDOW_DAYS")
|
||||
}
|
||||
with calib.patched(calib.VARIANTS["v2_reconstruction"]):
|
||||
assert rms.p3_drawdown is not before["p3_drawdown"]
|
||||
assert rms.HY_OAS_WINDOW_DAYS == 3653
|
||||
for name, original in before.items():
|
||||
assert getattr(rms, name) is original or getattr(rms, name) == original
|
||||
|
||||
def test_patching_restores_even_when_the_body_raises(self):
|
||||
original = rms.p5_volatility
|
||||
with pytest.raises(RuntimeError):
|
||||
with calib.patched(calib.VARIANTS["v3"]):
|
||||
raise RuntimeError("boom")
|
||||
assert rms.p5_volatility is original
|
||||
|
||||
def test_v4_variant_patches_nothing(self):
|
||||
"""The shipped methodology must be exercised as live code, not a copy,
|
||||
or the harness and the service can drift apart silently."""
|
||||
assert calib.VARIANTS["v4"] == {}
|
||||
|
||||
|
||||
class TestCandidateFormulas:
|
||||
def test_graduated_trend_break_never_exceeds_the_binary_one(self):
|
||||
"""Half of the provable state_v4 <= state_v3 invariant."""
|
||||
closes = [100.0] * 200
|
||||
for last in (105.0, 100.0, 99.0, 92.0, 80.0, 50.0):
|
||||
series = closes[:-1] + [last]
|
||||
v4 = rms._under_200(series) # shipped
|
||||
v3 = calib._v3_under_200(series) # retired
|
||||
assert v4 <= v3, f"close={last}: v4 {v4} > v3 {v3}"
|
||||
|
||||
def test_anchored_vix_never_exceeds_the_live_formula(self):
|
||||
for vix in (10, 15, 17, 20, 25, 30, 40, 55, 82):
|
||||
assert rms.p5_volatility(vix) <= calib._v3_p5_volatility(vix), f"vix={vix}"
|
||||
|
||||
def test_the_vix_table_keeps_resolving_past_thirty(self):
|
||||
assert rms.p5_volatility(30) < rms.p5_volatility(40) < rms.p5_volatility(50)
|
||||
assert rms.p5_volatility(55) == 100.0
|
||||
# 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):
|
||||
closes = [100.0] * 199 + [98.0] # ~2% below a flat 200-DMA
|
||||
assert calib._v3_under_200(closes) == 100.0 # retired: pegged
|
||||
assert rms._under_200(closes) < 40.0 # shipped: graded
|
||||
|
||||
def test_candidate_tables_are_well_formed(self):
|
||||
for table in (calib.P5_VIX_ANCHORS_A, calib.P5_VIX_ANCHORS_B,
|
||||
calib.P1_TREND_BREAK_ANCHORS):
|
||||
xs = [x for x, _ in table]
|
||||
ys = [y for _, y in table]
|
||||
assert xs == sorted(xs) and len(set(xs)) == len(xs)
|
||||
assert ys == sorted(ys)
|
||||
assert 0.0 <= min(ys) and max(ys) <= 100.0
|
||||
|
||||
|
||||
class TestGates:
|
||||
def _row(self, date_str, state, coverage=100.0, w1=5.0):
|
||||
return {
|
||||
"date": date_str,
|
||||
"state": {"score": state, "coverage": coverage, "pillars": []},
|
||||
"warning": {"score": 10.0, "coverage": 100.0, "pillars": [
|
||||
{"id": "breadth_divergence", "sensors": [{"id": "W1", "score": w1}]}
|
||||
]},
|
||||
"data_quality": {"stale_inputs": []},
|
||||
}
|
||||
|
||||
def test_the_invariant_gate_catches_a_v4_row_above_its_v3_row(self):
|
||||
v3 = [self._row("2026-01-02", 40.0), self._row("2026-01-05", 50.0)]
|
||||
v4 = [self._row("2026-01-02", 38.0), self._row("2026-01-05", 55.0)]
|
||||
gate = calib._invariant_gate(v3, v4)
|
||||
assert gate["passed"] is False
|
||||
assert gate["detail"][0]["date"] == "2026-01-05"
|
||||
|
||||
def test_the_invariant_gate_passes_when_v4_is_never_higher(self):
|
||||
v3 = [self._row("2026-01-02", 40.0)]
|
||||
v4 = [self._row("2026-01-02", 40.0)]
|
||||
assert calib._invariant_gate(v3, v4)["passed"] is True
|
||||
|
||||
def test_sensor_lookup_searches_both_axes(self):
|
||||
"""W1 lives under warning; a state-only search silently returns None and
|
||||
made the W1 census read 0."""
|
||||
row = self._row("2026-01-02", 40.0, w1=7.5)
|
||||
assert calib._sensor_score(row, "breadth_divergence", "W1") == 7.5
|
||||
|
||||
|
||||
class TestBandShares:
|
||||
def test_shares_use_the_candidate_bands_not_the_imported_default(self):
|
||||
"""band_for binds bands=STATE_BANDS as an import-time default, so the
|
||||
grid must pass candidates explicitly or every row scores identically."""
|
||||
scores = [10.0, 30.0, 55.0, 75.0]
|
||||
loose = calib._band_shares(scores, (20.0, 50.0, 80.0))
|
||||
tight = calib._band_shares(scores, (20.0, 50.0, 60.0))
|
||||
assert loose["breaking"] == 0.0
|
||||
assert tight["breaking"] == 25.0
|
||||
|
||||
def test_shares_sum_to_one_hundred(self):
|
||||
shares = calib._band_shares([5.0, 25.0, 55.0, 85.0], (20.0, 50.0, 80.0))
|
||||
assert sum(shares.values()) == pytest.approx(100.0)
|
||||
|
||||
|
||||
class TestScenarios:
|
||||
def test_the_decisive_scenario_is_computed_not_asserted(self):
|
||||
rows = {s["scenario"].split()[0]: s for s in
|
||||
calib._scenarios(calib.P5_VIX_ANCHORS_A, calib.P1_TREND_BREAK_ANCHORS)}
|
||||
# A 2022-style AI/tech drawdown with genuinely calm credit. This is the
|
||||
# meaning anchor for the breaking threshold, so it is machine-checked.
|
||||
assert rows["S3a"]["C1"] == 0.0
|
||||
assert rows["S3a"]["state"] == pytest.approx(70.33, abs=0.01)
|
||||
assert rows["S3b"]["state"] == pytest.approx(74.00, abs=0.01)
|
||||
# ...and it must clear the chosen threshold under either P2 assumption.
|
||||
assert min(rows["S3a"]["state"], rows["S3b"]["state"]) > 65.0
|
||||
assert rows["S1"]["state"] < 20.0
|
||||
|
||||
|
||||
class TestRefusals:
|
||||
"""The harness's safety contract: it must decline rather than under-report.
|
||||
|
||||
Both paths return before any network call, so these are fast and offline.
|
||||
"""
|
||||
|
||||
def _run(self, argv, monkeypatch):
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["run_regime_monitor_calibration.py", *argv])
|
||||
return asyncio.run(calib._main())
|
||||
|
||||
def test_refuses_without_every_required_variant(self, monkeypatch):
|
||||
assert self._run(["--methodology", "v3,v4"], monkeypatch) == 2
|
||||
assert self._run(["--methodology", "v2_reconstruction,v3"], monkeypatch) == 2
|
||||
|
||||
def test_refuses_an_unknown_variant(self, monkeypatch):
|
||||
assert self._run(["--methodology", "v3,v4,nonsense"], monkeypatch) == 2
|
||||
|
||||
def test_refuses_a_custom_window_without_a_calendar_anchor(self, monkeypatch):
|
||||
"""--sessions alone can only ever be tautological, so the start date must
|
||||
be supplied explicitly once the published window is left behind."""
|
||||
assert self._run(
|
||||
["--methodology", ",".join(calib.REQUIRED_VARIANTS), "--sessions", "100"],
|
||||
monkeypatch,
|
||||
) == 2
|
||||
assert self._run(
|
||||
["--methodology", ",".join(calib.REQUIRED_VARIANTS), "--end", "2026-01-05"],
|
||||
monkeypatch,
|
||||
) == 2
|
||||
|
||||
def test_the_default_invocation_satisfies_its_own_requirement(self, monkeypatch):
|
||||
"""A default that the requirement rejects would make every bare run fail."""
|
||||
import sys
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["run_regime_monitor_calibration.py"])
|
||||
default = calib._parse_args().methodology.split(",")
|
||||
assert set(calib.REQUIRED_VARIANTS) <= set(default)
|
||||
assert set(calib.REQUIRED_VARIANTS) <= set(calib.VARIANTS)
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,10 @@ def test_band_for_is_per_axis():
|
||||
assert band_for(20, STATE_BANDS) == "watch"
|
||||
assert band_for(50, STATE_BANDS) == "elevated"
|
||||
assert band_for(80, STATE_BANDS) == "breaking"
|
||||
# v4 moved the top band 80 -> 65; pin the new boundary from both sides so a
|
||||
# silent revert cannot pass. band_for is inclusive at the threshold.
|
||||
assert band_for(64.9, STATE_BANDS) == "elevated"
|
||||
assert band_for(65, STATE_BANDS) == "breaking"
|
||||
# Warning's realized range is far narrower, so it gets its own thresholds.
|
||||
assert band_for(45, STATE_BANDS) == "watch"
|
||||
assert band_for(45, WARNING_BANDS) == "elevated"
|
||||
@@ -172,7 +176,7 @@ def test_relative_strength_flat_or_better_is_zero():
|
||||
|
||||
def test_volatility_and_breadth_zero_points():
|
||||
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(20) == 100
|
||||
assert breadth_level_score(None) is None
|
||||
@@ -363,7 +367,7 @@ def test_fundamental_api_rejects_numeric_ordinal_overrides():
|
||||
|
||||
|
||||
@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):
|
||||
return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"})
|
||||
|
||||
@@ -371,24 +375,29 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(monkeypatch):
|
||||
|
||||
result = await rms.get_fundamental_overrides(object())
|
||||
|
||||
assert result["methodology"] == "v3"
|
||||
assert result["methodology"] == "v4"
|
||||
assert result["f1_score"] is None
|
||||
assert result["f3_score"] is None
|
||||
assert result["good_news_stock_down"] == "mixed"
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
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.
|
||||
|
||||
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"]
|
||||
|
||||
async def fake_value(_db, _key):
|
||||
return json.dumps({
|
||||
"methodology": "v2",
|
||||
"methodology": stored_methodology,
|
||||
"f1_score": 0.0, # stale v2 scale, must be recomputed
|
||||
"f3_score": 100.0,
|
||||
"capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")},
|
||||
@@ -396,6 +405,7 @@ async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
|
||||
"source": "gemini",
|
||||
"fetched_at": "2026-07-24T14:25:47+00:00",
|
||||
"effective_date": "2026-07-27",
|
||||
"locked": True,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(rms.settings_store, "get_value", fake_value)
|
||||
@@ -405,7 +415,11 @@ async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
|
||||
assert result["source"] == "gemini"
|
||||
assert result["good_news_stock_down"] == "yes"
|
||||
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
|
||||
# locked is the operator saying "do not overwrite this". Losing it is half the
|
||||
# failure mode: update_regime_monitor only auto-refreshes when locked is false.
|
||||
assert result["locked"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -484,7 +498,7 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
|
||||
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
|
||||
snapshot_date = date(2026, 6, 26)
|
||||
first = {
|
||||
"methodology": "v3",
|
||||
"methodology": "v4",
|
||||
"date": snapshot_date.isoformat(),
|
||||
"state": {"score": 10.0, "band": "stable"},
|
||||
"warning": {"score": 20.0, "band": "stable"},
|
||||
@@ -540,7 +554,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
||||
return {}, {}
|
||||
|
||||
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):
|
||||
rewrites.append(rewrite_existing)
|
||||
@@ -568,9 +582,9 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
||||
@pytest.mark.parametrize(
|
||||
("stored", "expect_reseed"),
|
||||
[
|
||||
({"methodology": "v3"}, True), # written before the marker existed
|
||||
({"methodology": "v3", "sensor_revision": 1}, True),
|
||||
({"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}, False),
|
||||
({"methodology": "v4"}, True), # written before the marker existed
|
||||
({"methodology": "v4", "sensor_revision": 1}, True),
|
||||
({"methodology": "v4", "sensor_revision": rms.SENSOR_REVISION}, False),
|
||||
],
|
||||
)
|
||||
async def test_a_stale_sensor_revision_reseeds_stored_history(
|
||||
@@ -708,6 +722,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")
|
||||
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
|
||||
assert price["score"] == max(sensor_scores)
|
||||
assert result["methodology"] == "v3"
|
||||
assert result["methodology"] == "v4"
|
||||
assert "combined" not in result
|
||||
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}"
|
||||
|
||||
Reference in New Issue
Block a user