Compare commits
6
Commits
| 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
|
# Runtime A5 parity bundles are generated on the production server. Research
|
||||||
# conclusions belong in docs/research, not as an ever-growing artifact archive.
|
# conclusions belong in docs/research, not as an ever-growing artifact archive.
|
||||||
reports/fundamentals-parity/
|
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.
|
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
|
||||||
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
||||||
3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v3 risk thermometer; feed no trades.
|
3. **Market Trend (SPY)** + **AI/Tech Risk Monitor** — the SPY trend guard and the v4 risk thermometer; feed no trades.
|
||||||
4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
|
4. **Telegram alerts** — change-driven (risk-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
|
||||||
|
|
||||||
**Near-close** (~15:30 ET Mon–Fri) — the only full-universe qualifying observation:
|
**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
|
The monitor is a risk thermometer, not a probability or trading rule. It keeps
|
||||||
two deliberately separate outputs:
|
two deliberately separate outputs:
|
||||||
@@ -8,13 +8,13 @@ two deliberately separate outputs:
|
|||||||
relative strength, credit impulse).
|
relative strength, credit impulse).
|
||||||
|
|
||||||
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
||||||
earnings-reaction observations are a qualitative *overlay* in v3 rather than
|
earnings-reaction observations are a qualitative *overlay* since v3 rather than
|
||||||
weighted sensors: at a combined 20 points they could not reach the event
|
weighted sensors: at a combined 20 points they could not reach the event
|
||||||
study's alarm threshold even when both pegged, so refreshing them appeared to
|
study's alarm threshold even when both pegged, so refreshing them appeared to
|
||||||
do nothing. They are reported next to the scores instead of inside them.
|
do nothing. They are reported next to the scores instead of inside them.
|
||||||
|
|
||||||
Daily snapshots are the point-in-time record. The first run under a new
|
Daily snapshots are the point-in-time record. The first run under a new
|
||||||
``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once;
|
``METHODOLOGY`` rewrites every session inside ``REBUILD_LOOKBACK_DAYS`` once;
|
||||||
ordinary runs thereafter only upsert the latest trading date. The overlay is
|
ordinary runs thereafter only upsert the latest trading date. The overlay is
|
||||||
still gated by its effective date so a rebuild cannot stamp today's observation
|
still gated by its effective date so a rebuild cannot stamp today's observation
|
||||||
onto historical snapshots.
|
onto historical snapshots.
|
||||||
@@ -48,10 +48,14 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
|
|||||||
KEY_CONFIG = "regime_monitor_config"
|
KEY_CONFIG = "regime_monitor_config"
|
||||||
KEY_FUNDAMENTALS = "regime_fundamental_overrides"
|
KEY_FUNDAMENTALS = "regime_fundamental_overrides"
|
||||||
|
|
||||||
METHODOLOGY = "v3"
|
METHODOLOGY = "v4"
|
||||||
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
# Snapshots are reseeded on a methodology bump, but fundamental observations are
|
||||||
# collected by hand/LLM and carried across it when the format is compatible.
|
# collected by hand/LLM and carried across it when the format is compatible.
|
||||||
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
# EVERY methodology sharing the categorical format must be listed: this is checked
|
||||||
|
# against the *stored* blob, so omitting the current one discards the observation
|
||||||
|
# on its first write, which leaves fetched_at null and locked false -- and then
|
||||||
|
# update_regime_monitor refreshes it via the LLM on every single run, forever.
|
||||||
|
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3", "v4"})
|
||||||
|
|
||||||
# Bumped when a fix changes what historical rows *should* contain without
|
# Bumped when a fix changes what historical rows *should* contain without
|
||||||
# changing the live formula, so stored history needs one reseed. Deliberately
|
# changing the live formula, so stored history needs one reseed. Deliberately
|
||||||
@@ -59,6 +63,10 @@ CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
|
|||||||
# study, neither of which is warranted here -- the study recomputes its Warning
|
# study, neither of which is warranted here -- the study recomputes its Warning
|
||||||
# series from source rather than reading snapshots, so a reseed cannot stale it.
|
# series from source rather than reading snapshots, so a reseed cannot stale it.
|
||||||
# Snapshots written before this marker existed carry no key and read as 1.
|
# Snapshots written before this marker existed carry no key and read as 1.
|
||||||
|
# Deliberately NOT bumped for v4: a METHODOLOGY change already forces a full
|
||||||
|
# reseed (every stored row fails _parse_snapshot, so _latest_snapshot_row returns
|
||||||
|
# None and rebuilding is True). Bumping both would imply the reseed was
|
||||||
|
# revision-driven.
|
||||||
SENSOR_REVISION = 2
|
SENSOR_REVISION = 2
|
||||||
MIN_COVERAGE = 75.0
|
MIN_COVERAGE = 75.0
|
||||||
SOURCE_MAX_LAG_DAYS = 7
|
SOURCE_MAX_LAG_DAYS = 7
|
||||||
@@ -68,9 +76,18 @@ SOURCE_MAX_LAG_DAYS = 7
|
|||||||
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
|
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
|
||||||
# numbers chosen so each band covers a sane share of history, not percentile
|
# numbers chosen so each band covers a sane share of history, not percentile
|
||||||
# fits -- percentile-derived bands would drift on every rebuild and silently
|
# fits -- percentile-derived bands would drift on every rebuild and silently
|
||||||
# rewrite what past snapshots meant. Realized shares over the 408 sessions to
|
# rewrite what past snapshots meant.
|
||||||
# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%.
|
#
|
||||||
STATE_BANDS = (20.0, 50.0, 80.0)
|
# v4 moved State's top band 80 -> 65, and only that one. With credit calm it
|
||||||
|
# scores 0.0 (not None) and still holds its full 20 points, so price + breadth +
|
||||||
|
# volatility at *literal maximum* summed to exactly 80.0 -- the old threshold, to
|
||||||
|
# the decimal, with nothing to spare. A 2022-style AI/tech drawdown with calm
|
||||||
|
# credit computes to 70.3-74.0 depending on whether a death cross has formed, so
|
||||||
|
# at 80 the case this monitor exists to measure could not print the top band.
|
||||||
|
# 65 clears it under either assumption. Realized shares over the 408 sessions to
|
||||||
|
# 2026-07-24, reported not fitted: State 78.9/13.0/4.7/3.4%, Warning 69/20/8/3%.
|
||||||
|
# The v4 breaking share (3.4%) matches v3's, which was arrived at independently.
|
||||||
|
STATE_BANDS = (20.0, 50.0, 65.0)
|
||||||
WARNING_BANDS = (20.0, 40.0, 60.0)
|
WARNING_BANDS = (20.0, 40.0, 60.0)
|
||||||
|
|
||||||
QUADRANT_STATE_DIVIDER = 50.0
|
QUADRANT_STATE_DIVIDER = 50.0
|
||||||
@@ -118,6 +135,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),
|
(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 = {
|
STATE_WEIGHTS = {
|
||||||
"price": 40.0,
|
"price": 40.0,
|
||||||
"breadth": 25.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:
|
def _under_200(closes: list[float]) -> float | None:
|
||||||
|
"""Trend break graded by depth below the 200-DMA, not a bare yes/no.
|
||||||
|
|
||||||
|
Through v3 this returned 0 or 100, so P1 printed 100 the moment SMH and QQQ
|
||||||
|
were both under their average -- and because the price pillar takes
|
||||||
|
``max(P1, P2, P3)``, that pinned the pillar and stopped P3's anchored ladder
|
||||||
|
resolving anything for the whole of a selloff. It pegged on 46 of the 408
|
||||||
|
sessions to 2026-07-24; under this table, none.
|
||||||
|
|
||||||
|
The step at the crossing (0 -> 20) is deliberate: the break itself is a
|
||||||
|
genuine binary event and deserves a floor. Only the depth past it is graded.
|
||||||
|
"""
|
||||||
sma200 = _sma(closes, 200)
|
sma200 = _sma(closes, 200)
|
||||||
if sma200 is None:
|
if sma200 is None or sma200 <= 0:
|
||||||
return None
|
return None
|
||||||
return 100.0 if closes[-1] < sma200 else 0.0
|
pct_below = (sma200 - closes[-1]) / sma200 * 100.0
|
||||||
|
if pct_below <= 0:
|
||||||
|
return 0.0
|
||||||
|
return _clamp(_interpolate(pct_below, P1_TREND_BREAK_ANCHORS))
|
||||||
|
|
||||||
|
|
||||||
def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
|
def p1_trend_break(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
|
||||||
@@ -288,9 +340,17 @@ def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60)
|
|||||||
|
|
||||||
|
|
||||||
def p5_volatility(vix: float | None) -> float | None:
|
def p5_volatility(vix: float | None) -> float | None:
|
||||||
|
"""VIX level against named anchors, so it keeps resolving past a 30 print.
|
||||||
|
|
||||||
|
v3 used ``(vix - 15) / 15``, which reached 100 at VIX 30 -- the same
|
||||||
|
saturation v3 itself had just removed from P3. VIX 30 is a bad week, 50 is a
|
||||||
|
crisis and 82 was March 2020, and all three scored identically. In the 408
|
||||||
|
sessions to 2026-07-24 that flattened five distinct April-2025 prints
|
||||||
|
(52.33, 46.98, 45.31, 40.72, 38.57) into a single 100.
|
||||||
|
"""
|
||||||
if vix is None:
|
if vix is None:
|
||||||
return None
|
return None
|
||||||
return _clamp((vix - 15.0) / 15.0 * 100.0)
|
return _clamp(_interpolate(vix, P5_VIX_ANCHORS))
|
||||||
|
|
||||||
|
|
||||||
def breadth_level_score(pct_above_200: float | None) -> float | None:
|
def breadth_level_score(pct_above_200: float | None) -> float | None:
|
||||||
@@ -513,7 +573,7 @@ def _overlay_timing(
|
|||||||
|
|
||||||
|
|
||||||
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
|
||||||
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
|
"""Point-in-time qualitative overlay. Never feeds State or Warning since v3.
|
||||||
|
|
||||||
The effective-date gate stays even though nothing is scored from this: the
|
The effective-date gate stays even though nothing is scored from this: the
|
||||||
400-session rebuild replays historical dates, and stamping today's LLM read
|
400-session rebuild replays historical dates, and stamping today's LLM read
|
||||||
@@ -597,7 +657,7 @@ def _compute_index(
|
|||||||
divergence_series: Series | None = None,
|
divergence_series: Series | None = None,
|
||||||
breadth_counts: dict[date, int] | None = None,
|
breadth_counts: dict[date, int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Compute the complete v2 State/Warning snapshot as of one trading date."""
|
"""Compute the complete State/Warning snapshot as of one trading date."""
|
||||||
tickers = config["tickers"]
|
tickers = config["tickers"]
|
||||||
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
smh = _closes_asof(prices.get(tickers["leaders"][0], []), as_of)
|
||||||
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
|
qqq = _closes_asof(prices.get(tickers["confirm"][0], []), as_of)
|
||||||
@@ -772,7 +832,7 @@ async def get_regime_config(db: AsyncSession) -> dict:
|
|||||||
if stored.get("fundamental_staleness_days") is not None:
|
if stored.get("fundamental_staleness_days") is not None:
|
||||||
cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"])
|
cfg["fundamental_staleness_days"] = int(stored["fundamental_staleness_days"])
|
||||||
except (TypeError, ValueError, ValidationError):
|
except (TypeError, ValueError, ValidationError):
|
||||||
logger.warning("Corrupt %s; using v2 defaults", KEY_CONFIG)
|
logger.warning("Corrupt %s; using defaults", KEY_CONFIG)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -214,3 +214,15 @@ it. See the [frozen specification](portfolio-capacity-bracket.md) and the
|
|||||||
[capacity findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
|
[capacity findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens).
|
||||||
|
|
||||||
The next real evidence is **forward**, not backward: the live paper-trade record.
|
The next real evidence is **forward**, not backward: the live paper-trade record.
|
||||||
|
|
||||||
|
## AI/Tech Risk Monitor
|
||||||
|
|
||||||
|
An observational risk thermometer (State + Warning) shown on the Risk page. It
|
||||||
|
gates nothing — no entries, exits, sizing or ranking — so it is not a strategy
|
||||||
|
document, but its calibration follows the same rules as one.
|
||||||
|
|
||||||
|
- [Methodology, v4](regime-monitor-v4.md) — sensors, weights, bands, and the
|
||||||
|
reasoning behind each cut from v2 onward.
|
||||||
|
- Reproduce any number in it with `scripts/run_regime_monitor_calibration.py`,
|
||||||
|
which replays the series offline and refuses to report unless it first
|
||||||
|
reproduces the published v2 and v3 figures.
|
||||||
|
|||||||
@@ -1,322 +1,6 @@
|
|||||||
# AI/Tech Risk Monitor v3 methodology
|
# Moved
|
||||||
|
|
||||||
Named "Regime Monitor" until 2026-08-07; the filename, the `regime_monitor` job
|
The methodology doc now lives at [regime-monitor-v4.md](regime-monitor-v4.md).
|
||||||
id, the `/regime` route and the `METHODOLOGY`/snapshot fields keep the old word,
|
|
||||||
because those are persisted or externally linked. Only the wording changed.
|
|
||||||
|
|
||||||
The AI/Tech Risk Monitor is an observational risk thermometer. It does not
|
v3's text is in git history (`git log --follow docs/research/regime-monitor-v4.md`).
|
||||||
gate entries, exits, position size, ranking, or alerts about individual setups.
|
This stub exists because commit messages up to 2026-08-08 cite the old path.
|
||||||
|
|
||||||
v3 supersedes v2. Every parameter below was calibrated against the 408 v2
|
|
||||||
sessions ending 2026-07-24, reproduced offline from the same Alpaca and FRED
|
|
||||||
inputs the live job uses; the reproduction matched the stored prod distribution
|
|
||||||
exactly (State avg 22.6/22.7, p80 35.1, max 91.2, P3 pegged 39, W1 live 108).
|
|
||||||
|
|
||||||
## What changed and why
|
|
||||||
|
|
||||||
**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down)
|
|
||||||
carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a
|
|
||||||
Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and
|
|
||||||
still inside the "stable" band. The sourced observation could not change any
|
|
||||||
published conclusion, so refreshing it looked like it did nothing. They are now
|
|
||||||
a qualitative overlay reported beside the scores. Capex also stopped scoring
|
|
||||||
`raising` and `holding` identically at 0: `holding` is the deceleration case and
|
|
||||||
now scores 50, so a boom no longer reads the same as a stall.
|
|
||||||
|
|
||||||
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
|
|
||||||
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
|
|
||||||
sessions sat at exactly 100 with no resolution left, and the price pillar showed
|
|
||||||
the top band on 13.5% of sessions. v3 uses named anchors with headroom past the
|
|
||||||
observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did
|
|
||||||
instead of taking `max()`. P3's realized share of State falls from 65% to 40%,
|
|
||||||
matching its nominal weight.
|
|
||||||
|
|
||||||
**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero
|
|
||||||
below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing
|
|
||||||
in a calm tape. Its 20-session rate of change still does, and spread widening is
|
|
||||||
a classic lead.
|
|
||||||
|
|
||||||
**The credit percentile leg was removed.** Its reference window silently shrank
|
|
||||||
from 10 years to 3 when ICE restricted the upstream series in April 2026, after
|
|
||||||
which it scored 20 points of stress at a spread the same sensor's anchors call
|
|
||||||
"mild". See Calibration below.
|
|
||||||
|
|
||||||
**Breadth loss counts during declines.** v2's divergence gate was
|
|
||||||
`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the
|
|
||||||
basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and
|
|
||||||
Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts
|
|
||||||
fully when price masks it (true divergence, the dangerous pre-top case) and at
|
|
||||||
35% when price confirms it. Breadth *level* lives in State, but breadth
|
|
||||||
*velocity* appears nowhere else, so this is not double counting.
|
|
||||||
|
|
||||||
**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while
|
|
||||||
State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The
|
|
||||||
upper half of the Warning axis was unreachable.
|
|
||||||
|
|
||||||
## Outputs
|
|
||||||
|
|
||||||
**State** — current structural stress:
|
|
||||||
|
|
||||||
- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads.
|
|
||||||
- Fixed-basket breadth level, 25%.
|
|
||||||
- HY option-adjusted credit spread level, 20%.
|
|
||||||
- VIX level, 15%.
|
|
||||||
|
|
||||||
**Warning** — deterioration and divergence:
|
|
||||||
|
|
||||||
- Fixed-basket breadth divergence, 45%.
|
|
||||||
- 60-session SMH/SPY relative-strength deterioration, 30%.
|
|
||||||
- HY OAS 20-session widening, 25%.
|
|
||||||
|
|
||||||
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3.
|
|
||||||
|
|
||||||
## Calibration
|
|
||||||
|
|
||||||
P3 drawdown anchors, as (drawdown %, score): 0→0, 4→10, 8→25, 16→50, 28→78,
|
|
||||||
40→100, flat outside. Credit impulse is relative (+35% over 20 sessions = 100)
|
|
||||||
rather than absolute, because +0.5pp means something very different at an OAS of
|
|
||||||
2.7 than at 8.0.
|
|
||||||
|
|
||||||
Bands are round, meaning-anchored numbers, not percentile fits — percentile
|
|
||||||
thresholds would drift on every rebuild and silently rewrite what past snapshots
|
|
||||||
meant. Realized shares over the calibration window:
|
|
||||||
|
|
||||||
| Axis | stable | watch | elevated | breaking | thresholds |
|
|
||||||
|------|--------|-------|----------|----------|------------|
|
|
||||||
| State | 73.3% | 15.0% | 8.3% | 3.4% | 20 / 50 / 80 |
|
|
||||||
| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 |
|
|
||||||
|
|
||||||
Quadrant dividers sit at each axis's watch/elevated boundary: State 50,
|
|
||||||
Warning 40.
|
|
||||||
|
|
||||||
Scores renormalize over available fixed weights, but a band is published only at
|
|
||||||
75% or greater coverage. Trend deltas are suppressed when the participating
|
|
||||||
pillar set changes. Zero means ordinary/healthy; only stress contributes.
|
|
||||||
|
|
||||||
Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0
|
|
||||||
stressed, linear between, and nothing else. v2 blended those anchors at 70% with
|
|
||||||
a 30% upper-tail percentile over a nominally 10-year window.
|
|
||||||
|
|
||||||
That leg was removed rather than repaired. ICE restricted FRED to a rolling
|
|
||||||
3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it
|
|
||||||
outright ("Starting in April 2026, this series will only include 3 years of
|
|
||||||
observations"), and an unbounded request returns the same 795 observations as a
|
|
||||||
30-year one. The v2 percentile therefore ranked the current spread against three
|
|
||||||
uniformly tight years (range 2.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.**
|
|
||||||
|
|||||||
@@ -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 STATE_COLOR = '#60a5fa';
|
||||||
const WARNING_COLOR = '#fb923c';
|
const WARNING_COLOR = '#fb923c';
|
||||||
|
|
||||||
// Fall back to the v3 constants, not v2's shared 60/60, so a missing
|
// Fall back to the shipped constants, not v2's shared 60/60, so a missing
|
||||||
// quadrant_config cannot draw dividers that disagree with the alert path.
|
// quadrant_config cannot draw dividers that disagree with the alert path.
|
||||||
const DEFAULT_STATE_DIVIDER = 50;
|
const DEFAULT_STATE_DIVIDER = 50;
|
||||||
const DEFAULT_WARNING_DIVIDER = 40;
|
const DEFAULT_WARNING_DIVIDER = 40;
|
||||||
|
|||||||
@@ -562,7 +562,7 @@ export interface RegimeMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RegimeFundamentals {
|
export interface RegimeFundamentals {
|
||||||
methodology: 'v3';
|
methodology: 'v4';
|
||||||
f1_score: number | null;
|
f1_score: number | null;
|
||||||
f3_score: number | null;
|
f3_score: number | null;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
|
|||||||
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
|
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(20, STATE_BANDS) == "watch"
|
||||||
assert band_for(50, STATE_BANDS) == "elevated"
|
assert band_for(50, STATE_BANDS) == "elevated"
|
||||||
assert band_for(80, STATE_BANDS) == "breaking"
|
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.
|
# Warning's realized range is far narrower, so it gets its own thresholds.
|
||||||
assert band_for(45, STATE_BANDS) == "watch"
|
assert band_for(45, STATE_BANDS) == "watch"
|
||||||
assert band_for(45, WARNING_BANDS) == "elevated"
|
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():
|
def test_volatility_and_breadth_zero_points():
|
||||||
assert p5_volatility(15) == 0
|
assert p5_volatility(15) == 0
|
||||||
assert p5_volatility(30) == 100
|
assert p5_volatility(30) == 55
|
||||||
assert breadth_level_score(60) == 0
|
assert breadth_level_score(60) == 0
|
||||||
assert breadth_level_score(20) == 100
|
assert breadth_level_score(20) == 100
|
||||||
assert breadth_level_score(None) is None
|
assert breadth_level_score(None) is None
|
||||||
@@ -363,7 +367,7 @@ def test_fundamental_api_rejects_numeric_ordinal_overrides():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(monkeypatch):
|
async def test_legacy_numeric_fundamentals_do_not_leak_into_v4(monkeypatch):
|
||||||
async def fake_value(_db, _key):
|
async def fake_value(_db, _key):
|
||||||
return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"})
|
return json.dumps({"f1_score": 75.0, "f3_score": 75.0, "source": "manual"})
|
||||||
|
|
||||||
@@ -371,24 +375,29 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(monkeypatch):
|
|||||||
|
|
||||||
result = await rms.get_fundamental_overrides(object())
|
result = await rms.get_fundamental_overrides(object())
|
||||||
|
|
||||||
assert result["methodology"] == "v3"
|
assert result["methodology"] == "v4"
|
||||||
assert result["f1_score"] is None
|
assert result["f1_score"] is None
|
||||||
assert result["f3_score"] is None
|
assert result["f3_score"] is None
|
||||||
assert result["good_news_stock_down"] == "mixed"
|
assert result["good_news_stock_down"] == "mixed"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
|
@pytest.mark.parametrize("stored_methodology", ["v2", "v3"])
|
||||||
|
async def test_v2_observation_survives_the_methodology_bump(monkeypatch, stored_methodology):
|
||||||
"""A snapshot reseed must not throw away a hand/LLM-collected observation.
|
"""A snapshot reseed must not throw away a hand/LLM-collected observation.
|
||||||
|
|
||||||
The categorical format is unchanged, so the stored capex map is still valid;
|
The categorical format is unchanged, so the stored capex map is still valid;
|
||||||
only the capex scale moved, and f1 is recomputed from the categories.
|
only the capex scale moved, and f1 is recomputed from the categories.
|
||||||
|
|
||||||
|
Parametrised over every methodology that could be sitting in the settings row
|
||||||
|
at cutover time -- "v3" is the one the v4 bump actually meets in production,
|
||||||
|
and losing it would silently start a paid LLM refresh on every run.
|
||||||
"""
|
"""
|
||||||
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
||||||
|
|
||||||
async def fake_value(_db, _key):
|
async def fake_value(_db, _key):
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"methodology": "v2",
|
"methodology": stored_methodology,
|
||||||
"f1_score": 0.0, # stale v2 scale, must be recomputed
|
"f1_score": 0.0, # stale v2 scale, must be recomputed
|
||||||
"f3_score": 100.0,
|
"f3_score": 100.0,
|
||||||
"capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")},
|
"capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")},
|
||||||
@@ -396,6 +405,7 @@ async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
|
|||||||
"source": "gemini",
|
"source": "gemini",
|
||||||
"fetched_at": "2026-07-24T14:25:47+00:00",
|
"fetched_at": "2026-07-24T14:25:47+00:00",
|
||||||
"effective_date": "2026-07-27",
|
"effective_date": "2026-07-27",
|
||||||
|
"locked": True,
|
||||||
})
|
})
|
||||||
|
|
||||||
monkeypatch.setattr(rms.settings_store, "get_value", fake_value)
|
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["source"] == "gemini"
|
||||||
assert result["good_news_stock_down"] == "yes"
|
assert result["good_news_stock_down"] == "yes"
|
||||||
assert result["effective_date"] == "2026-07-27"
|
assert result["effective_date"] == "2026-07-27"
|
||||||
assert result["f1_score"] == 37.5 # recomputed on the v3 scale, not the stored 0.0
|
assert result["f1_score"] == 37.5 # recomputed on the current scale, not the stored 0.0
|
||||||
|
assert result["fetched_at"] == "2026-07-24T14:25:47+00:00" # or a refresh loop starts
|
||||||
|
# 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
|
@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):
|
async def test_prior_snapshot_is_immutable_without_explicit_rebuild(db_session):
|
||||||
snapshot_date = date(2026, 6, 26)
|
snapshot_date = date(2026, 6, 26)
|
||||||
first = {
|
first = {
|
||||||
"methodology": "v3",
|
"methodology": "v4",
|
||||||
"date": snapshot_date.isoformat(),
|
"date": snapshot_date.isoformat(),
|
||||||
"state": {"score": 10.0, "band": "stable"},
|
"state": {"score": 10.0, "band": "stable"},
|
||||||
"warning": {"score": 20.0, "band": "stable"},
|
"warning": {"score": 20.0, "band": "stable"},
|
||||||
@@ -540,7 +554,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
|||||||
return {}, {}
|
return {}, {}
|
||||||
|
|
||||||
async def fake_latest(_db):
|
async def fake_latest(_db):
|
||||||
return object(), {"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}
|
return object(), {"methodology": "v4", "sensor_revision": rms.SENSOR_REVISION}
|
||||||
|
|
||||||
async def fake_upsert(_db, result, *, rewrite_existing):
|
async def fake_upsert(_db, result, *, rewrite_existing):
|
||||||
rewrites.append(rewrite_existing)
|
rewrites.append(rewrite_existing)
|
||||||
@@ -568,9 +582,9 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("stored", "expect_reseed"),
|
("stored", "expect_reseed"),
|
||||||
[
|
[
|
||||||
({"methodology": "v3"}, True), # written before the marker existed
|
({"methodology": "v4"}, True), # written before the marker existed
|
||||||
({"methodology": "v3", "sensor_revision": 1}, True),
|
({"methodology": "v4", "sensor_revision": 1}, True),
|
||||||
({"methodology": "v3", "sensor_revision": rms.SENSOR_REVISION}, False),
|
({"methodology": "v4", "sensor_revision": rms.SENSOR_REVISION}, False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_a_stale_sensor_revision_reseeds_stored_history(
|
async def test_a_stale_sensor_revision_reseeds_stored_history(
|
||||||
@@ -708,6 +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")
|
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
|
||||||
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
|
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
|
||||||
assert price["score"] == max(sensor_scores)
|
assert price["score"] == max(sensor_scores)
|
||||||
assert result["methodology"] == "v3"
|
assert result["methodology"] == "v4"
|
||||||
assert "combined" not in result
|
assert "combined" not in result
|
||||||
assert result["basket"]["members_available"] == 25
|
assert result["basket"]["members_available"] == 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_carries_categorical_fundamental_observations():
|
||||||
|
"""The costliest failure mode in the v3 -> v4 cut.
|
||||||
|
|
||||||
|
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES is checked against the *stored* blob.
|
||||||
|
Omit the current methodology and the first write discards the observation;
|
||||||
|
the default that replaces it has fetched_at None and locked False, so
|
||||||
|
_fundamentals_stale is true and update_regime_monitor fires a paid LLM
|
||||||
|
refresh on every run, forever, with the operator's locked read gone.
|
||||||
|
"""
|
||||||
|
assert rms.METHODOLOGY in rms.CATEGORICAL_FUNDAMENTAL_METHODOLOGIES
|
||||||
|
# Older categorical blobs must still carry forward across the bump.
|
||||||
|
assert {"v2", "v3"} <= rms.CATEGORICAL_FUNDAMENTAL_METHODOLOGIES
|
||||||
|
|
||||||
|
|
||||||
|
def test_quadrant_dividers_match_the_band_boundaries():
|
||||||
|
"""The doc asserts dividers sit at each axis's watch/elevated boundary.
|
||||||
|
|
||||||
|
Nothing enforced it, and alert_service keeps its own fallback copies -- so a
|
||||||
|
band move could silently leave the alert path classifying on the old grid.
|
||||||
|
"""
|
||||||
|
from app.services import alert_service
|
||||||
|
|
||||||
|
assert rms.QUADRANT_STATE_DIVIDER == STATE_BANDS[1]
|
||||||
|
assert rms.QUADRANT_WARNING_DIVIDER == WARNING_BANDS[1]
|
||||||
|
assert alert_service.QUAD_X_DIV == rms.QUADRANT_STATE_DIVIDER
|
||||||
|
assert alert_service.QUAD_Y_DIV == rms.QUADRANT_WARNING_DIVIDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_vix_sensor_keeps_headroom_past_a_thirty_print():
|
||||||
|
"""v3 read VIX 30, 50 and 82 as an identical 100 -- the same saturation v3
|
||||||
|
itself had just removed from P3."""
|
||||||
|
assert p5_volatility(30) < p5_volatility(40) < p5_volatility(50)
|
||||||
|
assert p5_volatility(55) == 100.0
|
||||||
|
assert p5_volatility(82) == 100.0
|
||||||
|
assert p5_volatility(15) == 0.0
|
||||||
|
assert p5_volatility(10) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_shallow_trend_break_does_not_peg_the_price_pillar():
|
||||||
|
"""v3's binary _under_200 printed 100 the moment price crossed, pinning the
|
||||||
|
pillar's max() and stopping P3's ladder resolving for the whole selloff."""
|
||||||
|
end = date(2026, 6, 26)
|
||||||
|
# ~2% below a flat 200-DMA, with a shallow drawdown to match.
|
||||||
|
flat = [100.0] * 260
|
||||||
|
shallow = flat[:-1] + [98.0]
|
||||||
|
prices = {
|
||||||
|
"SMH": _dated(shallow, end),
|
||||||
|
"QQQ": _dated(shallow, end),
|
||||||
|
"SPY": _dated(flat, end),
|
||||||
|
}
|
||||||
|
result = _compute_index(
|
||||||
|
prices, [(end, 16.0)], [(end, 2.8)], {},
|
||||||
|
copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 0.0)], {end: 30},
|
||||||
|
)
|
||||||
|
price = next(p for p in result["state"]["pillars"] if p["id"] == "price")
|
||||||
|
assert price["score"] < 40.0, "a 2% break must not read as maximum stress"
|
||||||
|
p1 = next(s for s in price["sensors"] if s["id"] == "P1")
|
||||||
|
assert 0.0 < p1["score"] < 40.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_tables_are_well_formed():
|
||||||
|
"""Cheap guard against a fat-fingered edit to any interpolation table."""
|
||||||
|
tables = {
|
||||||
|
"P3_DRAWDOWN_ANCHORS": rms.P3_DRAWDOWN_ANCHORS,
|
||||||
|
"P1_TREND_BREAK_ANCHORS": rms.P1_TREND_BREAK_ANCHORS,
|
||||||
|
"P5_VIX_ANCHORS": rms.P5_VIX_ANCHORS,
|
||||||
|
}
|
||||||
|
for name, table in tables.items():
|
||||||
|
xs = [x for x, _ in table]
|
||||||
|
ys = [y for _, y in table]
|
||||||
|
assert xs == sorted(xs) and len(set(xs)) == len(xs), f"{name}: x not increasing"
|
||||||
|
assert ys == sorted(ys), f"{name}: y not non-decreasing"
|
||||||
|
assert 0.0 <= min(ys) and max(ys) <= 100.0, f"{name}: out of [0,100]"
|
||||||
|
|
||||||
|
# Slopes ease off only on the two v4 tables. P3 is deliberately gentle at the
|
||||||
|
# onset then steepens (2.5, 3.75, 3.125, 2.33, 1.83), so it is excluded.
|
||||||
|
for name in ("P1_TREND_BREAK_ANCHORS", "P5_VIX_ANCHORS"):
|
||||||
|
table = tables[name]
|
||||||
|
slopes = [
|
||||||
|
(table[i + 1][1] - table[i][1]) / (table[i + 1][0] - table[i][0])
|
||||||
|
for i in range(len(table) - 1)
|
||||||
|
]
|
||||||
|
assert all(a >= b for a, b in zip(slopes, slopes[1:])), f"{name}: {slopes}"
|
||||||
|
|||||||
Reference in New Issue
Block a user