Rewrite Regime Monitor as v3: fundamentals off the score, desaturate P3
Deploy / lint (push) Successful in 11s
Deploy / test (push) Successful in 1m43s
Deploy / deploy (push) Successful in 38s

The LLM-sourced capex/earnings observations carried 12+8 of 100 Warning points,
so both pegged at 100 produced a Warning of 20.0 -- below the event study's 25.3
alarm threshold and still inside the "stable" band. The reading was
arithmetically incapable of changing anything on screen, which is why refreshing
it appeared to do nothing. They are now a qualitative overlay reported beside
the scores rather than diluted into them.

Calibrated against the 408 v2 sessions to 2026-07-24, reproduced offline from
Alpaca + FRED; the harness matched the stored prod distribution exactly before
any parameter was changed.

State:
- P3 used dd_pct * 5, reaching 100 at a 20% drawdown -- the 90th percentile of
  the observed distribution -- so 39/408 sessions sat at exactly 100 with no
  resolution left during the part of a selloff that matters most. Replaced with
  anchored breakpoints keeping headroom past the observed 36% maximum, blended
  2:1 like P1/P2 instead of max(). P3's realized share of State falls from 65%
  to 40%, matching its nominal weight.
- Credit level is now anchors-only. ICE capped FRED's BAMLH0A0HYM2 at a rolling
  3-year window in April 2026, silently turning the 10-year percentile leg into
  a 3-year one that scored 20 points of stress at an OAS of 3.5 -- the level its
  own anchors call "mild". The anchors already encode the long-run distribution.

Warning:
- Added HY OAS 20-session widening (25%). The level is pinned at zero below the
  3.5 anchor; its rate of change is not.
- Divergence tapers to a 0.35 floor instead of a hard price_ret >= 0 gate, which
  zeroed the sensor through every decline: on 2026-07-24 the basket shed 10
  points of participation in 20 sessions and Warning printed exactly 0.
- The event study and the live monitor now share one sensor definition, so they
  cannot silently drift apart.

Bands are per axis (State 20/50/80, Warning 20/40/60) with quadrant dividers at
50/40; v2 Warning never exceeded 64.9 against a shared 60, leaving that half of
the quadrant unreachable. Realized shares: State 73/15/8/3%, Warning 69/20/8/3%.

Snapshots now record credit_history_days and vix_history_days -- the percentile
defect went unnoticed for months because nothing asserted the window the code
claimed.

Cutover: the first run rebuilds 400 sessions automatically; the Event Study job
must be re-run, as its cached report self-invalidates on the methodology check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 14:36:57 +02:00
co-authored by Claude Opus 5
parent 49bf3b140e
commit 019ca1342a
12 changed files with 834 additions and 239 deletions
+2 -2
View File
@@ -133,7 +133,7 @@ indicators.
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years. 1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only. 2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
3. **Market Regime** + **Regime Monitor** — breadth/trend and the v2 risk thermometer; feed no trades. 3. **Market Regime** + **Regime Monitor** — breadth/trend and the v3 risk thermometer; feed no trades.
4. **Telegram alerts** — change-driven (regime-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan. 4. **Telegram alerts** — change-driven (regime-quadrant etc.); quiet days stay quiet. Setup alerts still fire on the near-close pipeline after the scan.
**Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation: **Near-close** (~15:30 ET MonFri) — the only full-universe qualifying observation:
@@ -319,7 +319,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
- Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge) - Activation gate — qualifies setups on a residual-momentum percentile floor (the actual selection), a headline gate-target R:R floor (prod: 2.0) and a 20% primary-target reach-probability floor (validated long-only edge)
- Recommendation layer — directional confidence, conflict detection, per-target reach-probability - Recommendation layer — directional confidence, conflict detection, per-target reach-probability
- Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation - Paper trading — take a setup, mark-to-market vs. latest close, auto-close per the exit policy (default: 3x ATR trail with a 30-trading-day max hold; time / percent-trailing / target-stop selectable), realized track record + outcome evaluation
- Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit, PIT fundamentals) with a manual chronological correction study - Market-regime guard + observational State/Warning monitor (fixed-basket breadth, VIX, credit level + impulse) with a manual chronological correction study
- Telegram alerts (e.g. regime-quadrant changes) - Telegram alerts (e.g. regime-quadrant changes)
- User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary - User-curated watchlist (cap: 20), enriched with composite score, R:R and S/R summary
- JWT auth with admin role, configurable registration, user access control - JWT auth with admin role, configurable registration, user access control
+2 -2
View File
@@ -97,8 +97,8 @@ SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML par
# Hysteresis (a deadband around each divider) stops a point sitting on a boundary # Hysteresis (a deadband around each divider) stops a point sitting on a boundary
# from flip-flopping; the cooldown caps how often a genuine change can re-alert. # from flip-flopping; the cooldown caps how often a genuine change can re-alert.
QUAD_TYPE = "regime_quadrant" QUAD_TYPE = "regime_quadrant"
QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative) QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
QUAD_Y_DIV = 60.0 # v2 Warning divider QUAD_Y_DIV = 40.0 # v3 Warning divider; the axes have different ranges
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = { QUAD_LABELS = {
+17 -6
View File
@@ -72,15 +72,25 @@ def _breadth_from_closes(
return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0] return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0]
# Breadth deterioration counts fully when price masks it (true divergence, the
# dangerous pre-top case) and at CONFIRMED_FLOOR when price falls with it.
# v2 used a hard ``price_ret >= 0`` cliff, which zeroed the sensor during every
# decline -- so on 2026-07-24, with the basket shedding 10 percentage points
# above their 200-DMA in 20 sessions, Warning read exactly 0. Breadth *level*
# lives in State but breadth *velocity* appears nowhere else, so partial credit
# here is not double counting.
DIVERGENCE_CONFIRMED_FLOOR = 0.35
DIVERGENCE_TAPER_PCT = 3.0
def compute_divergence_series( def compute_divergence_series(
breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20 breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20
) -> dict[date, float]: ) -> dict[date, float]:
"""Early-warning score (0-100, high = fragile) per date. """Early-warning score (0-100, high = fragile) per date.
This is deliberately a pure divergence: it is positive only when benchmark A 20 percentage-point breadth deterioration maps to 100 when the benchmark
price holds/rises while breadth falls. Absolute low breadth belongs in the is flat or rising, tapering to ``DIVERGENCE_CONFIRMED_FLOOR`` of that once
State score, so it is not counted again here. A 20 percentage-point breadth the benchmark is down ``DIVERGENCE_TAPER_PCT`` or more over the window.
deterioration maps to 100.
""" """
bench = {d: c for d, c in benchmark_closes} bench = {d: c for d, c in benchmark_closes}
common = sorted(d for d in bench if d in breadth) common = sorted(d for d in bench if d in breadth)
@@ -93,8 +103,9 @@ def compute_divergence_series(
price_ret = (bench[d] / price_past - 1.0) * 100.0 # % price_ret = (bench[d] / price_past - 1.0) * 100.0 # %
breadth_chg = breadth[d] - breadth[d0] # percentage points breadth_chg = breadth[d] - breadth[d0] # percentage points
deterioration = max(0.0, -breadth_chg) deterioration = max(0.0, -breadth_chg)
score = deterioration * 5.0 if price_ret >= 0 else 0.0 taper = max(0.0, min(1.0, (price_ret + DIVERGENCE_TAPER_PCT) / DIVERGENCE_TAPER_PCT))
out[d] = max(0.0, min(100.0, round(score, 2))) gate = DIVERGENCE_CONFIRMED_FLOOR + (1.0 - DIVERGENCE_CONFIRMED_FLOOR) * taper
out[d] = max(0.0, min(100.0, round(deterioration * 5.0 * gate, 2)))
return out return out
+22 -15
View File
@@ -149,29 +149,29 @@ def _warning_series(
breadth_divergence: dict[date, float], breadth_divergence: dict[date, float],
dates: list[date], dates: list[date],
config: dict, config: dict,
oas_series: rms.Series | None = None,
) -> dict[date, float]: ) -> dict[date, float]:
"""Technical Warning score used historically (fundamentals have no PIT history).""" """Warning score per session, from the monitor's own sensor definitions.
v2 re-derived this by hand from ``WARNING_WEIGHTS`` and so would have kept
measuring the old construct after a scoring change. Since v3 dropped
fundamentals from the score, this is now exactly the live Warning score
rather than a technical-only approximation of it.
"""
tickers = config["tickers"] tickers = config["tickers"]
smh_full = prices.get(tickers["leaders"][0], []) smh_full = prices.get(tickers["leaders"][0], [])
spy_full = prices.get(tickers["market"], []) spy_full = prices.get(tickers["market"], [])
out: dict[date, float] = {} out: dict[date, float] = {}
for session in dates: for session in dates:
divergence = breadth_divergence.get(session) sensors = rms.warning_sensor_scores(
relative = rms.p4_relative_strength( breadth_divergence.get(session),
rms._closes_asof(smh_full, session), rms._closes_asof(smh_full, session),
rms._closes_asof(spy_full, session), rms._closes_asof(spy_full, session),
rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS),
) )
values: list[tuple[float, float]] = [] score = rms.score_warning_sensors(sensors)
if divergence is not None: if score is not None:
values.append((divergence, rms.WARNING_WEIGHTS["breadth_divergence"])) out[session] = round(score, 2)
if relative is not None:
values.append((relative, rms.WARNING_WEIGHTS["relative_strength"]))
if values:
out[session] = round(
sum(value * weight for value, weight in values)
/ sum(weight for _, weight in values),
2,
)
return out return out
@@ -195,7 +195,13 @@ async def run_event_study(
db, config["breadth_basket"], window=200, min_tickers=20 db, config["breadth_basket"], window=200, min_tickers=20
) )
divergence = breadth_service.compute_divergence_series(breadth, benchmark) divergence = breadth_service.compute_divergence_series(breadth, benchmark)
warning = _warning_series(prices, divergence, dates, config) oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end)
warning = _warning_series(prices, divergence, dates, config, oas_series)
# The credit sensor cannot reach back as far as the price history does (the
# upstream series is capped at ~3 years), so the earlier part of the sample
# scores on W1+W2 alone via renormalisation. Report where W3 starts rather
# than letting the threshold quietly straddle two sensor sets.
credit_from = oas_series[0][0].isoformat() if oas_series else None
split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION))) split = max(1, min(len(dates) - 1, int(len(dates) * TRAIN_FRACTION)))
train_values = [warning[d] for d in dates[:split] if d in warning] train_values = [warning[d] for d in dates[:split] if d in warning]
@@ -243,6 +249,7 @@ async def run_event_study(
"train_fraction": TRAIN_FRACTION, "train_fraction": TRAIN_FRACTION,
"warn_percentile": WARN_PERCENTILE, "warn_percentile": WARN_PERCENTILE,
"warn_threshold": round(warn_threshold, 1), "warn_threshold": round(warn_threshold, 1),
"credit_sensor_from": credit_from,
"basket_hash": rms._basket_hash(config["breadth_basket"]), "basket_hash": rms._basket_hash(config["breadth_basket"]),
"basket_asof": config["basket_asof"], "basket_asof": config["basket_asof"],
}, },
+250 -78
View File
@@ -1,16 +1,23 @@
"""AI/Tech Regime Monitor v2. """AI/Tech Regime Monitor v3.
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:
* State: current structural stress (price, breadth, credit, volatility). * State: current structural stress (price, breadth, credit, volatility).
* Warning: deterioration/divergence that may precede State (breadth, relative * Warning: deterioration/divergence that may precede State (breadth divergence,
strength, and sourced fundamental observations). relative strength, credit impulse).
Daily snapshots are the point-in-time record. The first v2 run rewrites the Both scores are quantitative and daily. The sourced hyperscaler capex and
latest ``REBUILD_SESSIONS`` trading sessions once; ordinary runs thereafter only earnings-reaction observations are a qualitative *overlay* in v3 rather than
upsert the latest trading date. Fundamental observations are never replayed weighted sensors: at a combined 20 points they could not reach the event
before their effective date. study's alarm threshold even when both pegged, so refreshing them appeared to
do nothing. They are reported next to the scores instead of inside them.
Daily snapshots are the point-in-time record. The first run under a new
``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once;
ordinary runs thereafter only upsert the latest trading date. The overlay is
still gated by its effective date so a rebuild cannot stamp today's observation
onto historical snapshots.
""" """
from __future__ import annotations from __future__ import annotations
@@ -41,19 +48,51 @@ _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 = "v2" METHODOLOGY = "v3"
# Snapshots are reseeded on a methodology bump, but fundamental observations are
# collected by hand/LLM and carried across it when the format is compatible.
CATEGORICAL_FUNDAMENTAL_METHODOLOGIES = frozenset({"v2", "v3"})
REBUILD_SESSIONS = 400 REBUILD_SESSIONS = 400
MIN_COVERAGE = 75.0 MIN_COVERAGE = 75.0
SOURCE_MAX_LAG_DAYS = 7 SOURCE_MAX_LAG_DAYS = 7
QUADRANT_STATE_DIVIDER = 60.0 # Bands are per axis: the two scores have genuinely different realized ranges,
QUADRANT_WARNING_DIVIDER = 60.0 # so one shared set made Warning's top bands unreachable (v2 Warning never
# exceeded 64.9 in 408 sessions while State reached 91.2). Thresholds are round
# numbers chosen so each band covers a sane share of history, not percentile
# fits -- percentile-derived bands would drift on every rebuild and silently
# rewrite what past snapshots meant. Realized shares over the 408 sessions to
# 2026-07-24: State 73/15/8/3%, Warning 69/20/8/3%.
STATE_BANDS = (20.0, 50.0, 80.0)
WARNING_BANDS = (20.0, 40.0, 60.0)
QUADRANT_STATE_DIVIDER = 50.0
QUADRANT_WARNING_DIVIDER = 40.0
QUADRANT_MARGIN = 5.0 QUADRANT_MARGIN = 5.0
HY_OAS_MILD = 3.5 HY_OAS_MILD = 3.5
HY_OAS_ELEVATED = 5.0 HY_OAS_ELEVATED = 5.0
HY_OAS_STRESSED = 7.0 HY_OAS_STRESSED = 7.0
HY_OAS_REFERENCE_YEARS = 10.0 # ICE restricted FRED to a rolling 3-year window for BAMLH0A0HYM2 in April 2026
# ("Starting in April 2026, this series will only include 3 years of
# observations"), so v2's 10-year reference window silently became 3. Against 3
# years of uniformly tight spreads (2.59-4.61 over the calibration window) the
# blended upper-tail percentile saturated at an OAS of ~4.5 and scored 20 points
# of stress at 3.5 -- the level these anchors call "mild". The anchors already
# encode the long-run distribution, so the credit *level* is now purely anchored
# and credit *dynamics* live in W3 on the Warning axis where they belong.
HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now
W3_OAS_LOOKBACK = 20
W3_OAS_FULL_SCALE_PCT = 35.0
# Drawdown anchors (drawdown %, stress score). v2 used a bare ``dd_pct * 5``,
# which pegged at a 20% drawdown -- the 90th percentile of the observed
# distribution -- so 39 of 408 sessions sat at exactly 100 with no resolution
# left during the part of a selloff that matters most. These anchors keep
# headroom past the observed 36% maximum.
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),
)
STATE_WEIGHTS = { STATE_WEIGHTS = {
"price": 40.0, "price": 40.0,
@@ -61,11 +100,15 @@ STATE_WEIGHTS = {
"credit": 20.0, "credit": 20.0,
"volatility": 15.0, "volatility": 15.0,
} }
# Fundamentals left the score in v3. At 12 + 8 points they could not reach the
# event study's alarm threshold even when both pegged at 100, so the LLM read was
# decorative; it is now a separate qualitative overlay. Credit *impulse* takes
# their place because the OAS level is pinned at zero below the 3.5 anchor while
# its rate of change is not.
WARNING_WEIGHTS = { WARNING_WEIGHTS = {
"breadth_divergence": 50.0, "breadth_divergence": 45.0,
"relative_strength": 30.0, "relative_strength": 30.0,
"capex": 12.0, "credit_impulse": 25.0,
"earnings_reaction": 8.0,
} }
# Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor, # Fixed at the v2 launch. These are liquid S&P 500/Nasdaq AI, semiconductor,
@@ -92,7 +135,10 @@ DEFAULT_CONFIG: dict = {
CAPEX_STATES = ("raising", "holding", "cutting", "unknown") CAPEX_STATES = ("raising", "holding", "cutting", "unknown")
GNSD_STATES = ("yes", "no", "mixed") GNSD_STATES = ("yes", "no", "mixed")
_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 0.0, "cutting": 100.0} # v2 scored raising and holding identically at 0, so in a capex boom the reading
# was pinned at 0 and could not express the raising -> holding deceleration that
# is the actual early warning. Display-only in v3, but it should still describe.
_CAPEX_STATE_SCORES = {"raising": 0.0, "holding": 50.0, "cutting": 100.0}
_GNSD_SCORES = {"yes": 100.0, "no": 0.0} _GNSD_SCORES = {"yes": 100.0, "no": 0.0}
Series = list[tuple[date, float]] Series = list[tuple[date, float]]
@@ -127,12 +173,23 @@ def _blend(leader: float | None, confirm: float | None, leader_weight: float = 2
return sum(v * w for v, w in parts) / sum(w for _, w in parts) return sum(v * w for v, w in parts) / sum(w for _, w in parts)
def band_for(score: float) -> str: def _interpolate(x: float, anchors: tuple[tuple[float, float], ...]) -> float:
if score < 30: """Piecewise-linear lookup, flat outside the first and last anchor."""
if x <= anchors[0][0]:
return anchors[0][1]
for (x0, y0), (x1, y1) in zip(anchors, anchors[1:]):
if x <= x1:
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
return anchors[-1][1]
def band_for(score: float, bands: tuple[float, float, float] = STATE_BANDS) -> str:
watch, elevated, breaking = bands
if score < watch:
return "stable" return "stable"
if score < 60: if score < elevated:
return "watch" return "watch"
if score < 80: if score < breaking:
return "elevated" return "elevated"
return "breaking" return "breaking"
@@ -167,19 +224,29 @@ def p2_death_cross(smh: list[float], qqq: list[float], leader_weight: float = 2.
return _blend(_death_cross(smh), _death_cross(qqq), leader_weight) return _blend(_death_cross(smh), _death_cross(qqq), leader_weight)
def _drawdown(closes: list[float]) -> float | None: def drawdown_pct(closes: list[float]) -> float | None:
"""Percentage below the trailing 52-week closing high."""
if len(closes) < 30: if len(closes) < 30:
return None return None
peak = max(closes[-252:]) peak = max(closes[-252:])
if peak <= 0: if peak <= 0:
return None return None
dd_pct = (peak - closes[-1]) / peak * 100.0 return (peak - closes[-1]) / peak * 100.0
return _clamp(dd_pct * 5.0)
def p3_drawdown(smh: list[float], qqq: list[float]) -> float | None: def _drawdown(closes: list[float]) -> float | None:
vals = [v for v in (_drawdown(smh), _drawdown(qqq)) if v is not None] dd_pct = drawdown_pct(closes)
return max(vals) if vals else None return None if dd_pct is None else _clamp(_interpolate(dd_pct, P3_DRAWDOWN_ANCHORS))
def p3_drawdown(smh: list[float], qqq: list[float], leader_weight: float = 2.0) -> float | None:
"""Anchored drawdown stress on the same 2:1 leader/confirm blend P1 and P2 use.
v2 took ``max()`` here, which meant the more volatile leader always won and
the price pillar reduced to this one sensor: its realized share of State was
65% against a nominal 40% weight. Blending brings that back to 40%.
"""
return _blend(_drawdown(smh), _drawdown(qqq), leader_weight)
def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60) -> float | None: def p4_relative_strength(smh: list[float], spy: list[float], lookback: int = 60) -> float | None:
@@ -220,18 +287,68 @@ def _oas_absolute_score(value: float) -> float:
def f2_credit_spreads(oas_values: list[float]) -> float | None: def f2_credit_spreads(oas_values: list[float]) -> float | None:
"""HY OAS stress: 70% named absolute anchors + 30% upper-tail percentile.""" """HY OAS level against named absolute anchors (3.5 mild / 5.0 / 7.0).
v2 blended 70% of this with a 30% upper-tail percentile over the available
history. That leg was always a second, noisier estimate of what the anchors
already encode -- and once the usable window shrank to 3 uniformly tight
years it saturated far below any real stress level. Removed rather than
repaired: see ``HY_OAS_WINDOW_DAYS``.
"""
if not oas_values: if not oas_values:
return None return None
latest = oas_values[-1] return round(_oas_absolute_score(oas_values[-1]), 2)
absolute = _oas_absolute_score(latest)
if len(oas_values) < 30:
return round(absolute, 2) def w3_credit_impulse(
less = sum(1 for v in oas_values if v < latest) oas_values: list[float], lookback: int = W3_OAS_LOOKBACK
equal = sum(1 for v in oas_values if v == latest) ) -> float | None:
percentile = (less + 0.5 * equal) / len(oas_values) * 100.0 """HY OAS rate of change: widening only, relative so it works at any level.
relative = _clamp((percentile - 50.0) / 45.0 * 100.0)
return round(absolute * 0.7 + relative * 0.3, 2) The credit *level* (C1) sits at zero for as long as spreads stay under the
3.5 mild anchor -- 2.77 as of the v3 cutover -- so it contributes nothing to
State in a calm tape. The rate of change still does, and spread widening is
a classic lead, which is what Warning is for. Relative rather than absolute
because +0.5pp means something very different at 2.7 than at 8.0.
"""
if len(oas_values) < lookback + 1:
return None
past = oas_values[-lookback - 1]
if past <= 0:
return None
change_pct = (oas_values[-1] / past - 1.0) * 100.0
return _clamp(change_pct / W3_OAS_FULL_SCALE_PCT * 100.0)
def warning_sensor_scores(
divergence: float | None,
leader_closes: list[float],
market_closes: list[float],
oas_window: list[float],
) -> dict[str, float | None]:
"""The three Warning sensors, by pillar id.
Single definition so the live monitor and the event study cannot drift apart
-- in v2 the study re-derived the score from ``WARNING_WEIGHTS`` by hand and
would have silently kept measuring the old construct through this change.
"""
return {
"breadth_divergence": divergence,
"relative_strength": p4_relative_strength(leader_closes, market_closes),
"credit_impulse": w3_credit_impulse(oas_window),
}
def score_warning_sensors(sensors: dict[str, float | None]) -> float | None:
"""Weighted Warning score, renormalised over the sensors that are available."""
live = [
(float(score), float(WARNING_WEIGHTS[key]))
for key, score in sensors.items()
if score is not None and key in WARNING_WEIGHTS
]
if not live:
return None
return sum(s * w for s, w in live) / sum(w for _, w in live)
def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict: def _sensor(sensor_id: str, label: str, score: float | None, **details: object) -> dict:
@@ -244,7 +361,11 @@ def _sensor(sensor_id: str, label: str, score: float | None, **details: object)
} }
def _score_pillars(pillars: list[dict], weights: dict[str, float]) -> dict: def _score_pillars(
pillars: list[dict],
weights: dict[str, float],
bands: tuple[float, float, float] = STATE_BANDS,
) -> dict:
expected = sum(max(0.0, float(w)) for w in weights.values()) expected = sum(max(0.0, float(w)) for w in weights.values())
available_weight = sum( available_weight = sum(
max(0.0, float(weights.get(p["id"], 0.0))) max(0.0, float(weights.get(p["id"], 0.0)))
@@ -276,7 +397,12 @@ def _score_pillars(pillars: list[dict], weights: dict[str, float]) -> dict:
rounded = round(score, 1) if score is not None else None rounded = round(score, 1) if score is not None else None
return { return {
"score": rounded, "score": rounded,
"band": band_for(rounded) if rounded is not None and coverage >= MIN_COVERAGE else None, "band": (
band_for(rounded, bands)
if rounded is not None and coverage >= MIN_COVERAGE
else None
),
"bands": {"watch": bands[0], "elevated": bands[1], "breaking": bands[2]},
"coverage": round(coverage, 1), "coverage": round(coverage, 1),
"minimum_coverage": MIN_COVERAGE, "minimum_coverage": MIN_COVERAGE,
"available_pillars": [p["id"] for p in rows if p["available"]], "available_pillars": [p["id"] for p in rows if p["available"]],
@@ -309,13 +435,24 @@ def _value_asof(series: Series | None, as_of: date) -> float | None:
return item[1] if item else None return item[1] if item else None
def _window_asof(series: Series | None, as_of: date, years: float) -> list[float]: def _window_asof(series: Series | None, as_of: date, days: int) -> list[float]:
if not series: if not series:
return [] return []
start = as_of - timedelta(days=int(365.25 * years)) start = as_of - timedelta(days=days)
return [v for d, v in series if start <= d <= as_of] return [v for d, v in series if start <= d <= as_of]
def _coverage_days(series: Series | None, as_of: date) -> int | None:
"""Span of history actually available at ``as_of``.
Recorded in every snapshot because the v2 credit percentile degraded from a
10-year to a 3-year reference silently when the upstream licence changed --
nothing asserted the window it claimed, so nothing noticed for months.
"""
dates = [d for d, _ in series or [] if d <= as_of]
return (as_of - dates[0]).days if dates else None
def _next_weekday(d: date) -> date: def _next_weekday(d: date) -> date:
candidate = d + timedelta(days=1) candidate = d + timedelta(days=1)
while candidate.weekday() >= 5: while candidate.weekday() >= 5:
@@ -340,19 +477,31 @@ def _fundamental_effective_date(overrides: dict) -> date | None:
return _next_weekday(fetched) if fetched else None return _next_weekday(fetched) if fetched else None
def _fundamental_scores_asof(overrides: dict, config: dict, as_of: date) -> tuple[float | None, float | None, dict]: def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
The effective-date gate stays even though nothing is scored from this: the
400-session rebuild replays historical dates, and stamping today's LLM read
onto 2024 snapshots would be plain lookahead in the stored record.
"""
effective = _fundamental_effective_date(overrides) effective = _fundamental_effective_date(overrides)
if effective is None or as_of < effective: pending = effective is None or as_of < effective
return None, None, {"effective_date": effective.isoformat() if effective else None, "age_days": None} age = None if pending else (as_of - effective).days
age = (as_of - effective).days stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
stale = age > int(config.get("fundamental_staleness_days", 80)) return {
f1 = overrides.get("f1_score") "available": not pending and not stale,
f3 = overrides.get("f3_score") "pending": pending,
return ( "stale": stale,
None if stale or f1 is None else _clamp(float(f1)), "effective_date": effective.isoformat() if effective else None,
None if stale or f3 is None else _clamp(float(f3)), "age_days": age,
{"effective_date": effective.isoformat(), "age_days": age, "stale": stale}, "capex": None if pending else overrides.get("capex"),
) "good_news_stock_down": None if pending else overrides.get("good_news_stock_down"),
"capex_stress": None if pending else overrides.get("f1_score"),
"earnings_stress": None if pending else overrides.get("f3_score"),
"reasoning": None if pending else overrides.get("reasoning"),
"source": overrides.get("source"),
"fetched_at": overrides.get("fetched_at"),
}
def _basket_hash(symbols: list[str]) -> str: def _basket_hash(symbols: list[str]) -> str:
@@ -393,12 +542,14 @@ def _compute_index(
vix_item = _item_asof(vix_series, as_of) vix_item = _item_asof(vix_series, as_of)
vix_score = p5_volatility(vix_item[1] if vix_item else None) vix_score = p5_volatility(vix_item[1] if vix_item else None)
oas_item = _item_asof(oas_series, as_of) oas_item = _item_asof(oas_series, as_of)
oas_window = _window_asof(oas_series, as_of, HY_OAS_REFERENCE_YEARS) oas_window = _window_asof(oas_series, as_of, HY_OAS_WINDOW_DAYS)
credit_score = f2_credit_spreads(oas_window) credit_score = f2_credit_spreads(oas_window)
divergence = _value_asof(divergence_series, as_of) divergence = _value_asof(divergence_series, as_of)
relative_strength = p4_relative_strength(smh, spy) sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
f1, f3, fundamental_meta = _fundamental_scores_asof(overrides, config, as_of) relative_strength = sensors["relative_strength"]
credit_impulse = sensors["credit_impulse"]
overlay = fundamental_overlay(overrides, config, as_of)
state_pillars = [ state_pillars = [
{ {
@@ -444,21 +595,22 @@ def _compute_index(
"sensors": [_sensor("W2", "60-session relative-strength deterioration", relative_strength)], "sensors": [_sensor("W2", "60-session relative-strength deterioration", relative_strength)],
}, },
{ {
"id": "capex", "id": "credit_impulse",
"label": "Hyperscaler capex revisions", "label": "Credit impulse",
"score": round(f1, 1) if f1 is not None else None, "score": round(credit_impulse, 1) if credit_impulse is not None else None,
"sensors": [_sensor("F1", "Capex guidance cuts", f1)], "sensors": [
}, _sensor(
{ "W3",
"id": "earnings_reaction", f"HY OAS {W3_OAS_LOOKBACK}-session widening",
"label": "Good news, stock down", credit_impulse,
"score": round(f3, 1) if f3 is not None else None, oas=oas_item[1] if oas_item else None,
"sensors": [_sensor("F3", "Abnormal earnings reaction", f3)], )
],
}, },
] ]
state = _score_pillars(state_pillars, STATE_WEIGHTS) state = _score_pillars(state_pillars, STATE_WEIGHTS, STATE_BANDS)
warning = _score_pillars(warning_pillars, WARNING_WEIGHTS) warning = _score_pillars(warning_pillars, WARNING_WEIGHTS, WARNING_BANDS)
price_item = _item_asof(prices.get(tickers["leaders"][0]), as_of) price_item = _item_asof(prices.get(tickers["leaders"][0]), as_of)
dated_sources = { dated_sources = {
@@ -481,6 +633,7 @@ def _compute_index(
"date": as_of.isoformat(), "date": as_of.isoformat(),
"state": state, "state": state,
"warning": warning, "warning": warning,
"fundamental_overlay": overlay,
"quadrant_config": { "quadrant_config": {
"state_divider": QUADRANT_STATE_DIVIDER, "state_divider": QUADRANT_STATE_DIVIDER,
"warning_divider": QUADRANT_WARNING_DIVIDER, "warning_divider": QUADRANT_WARNING_DIVIDER,
@@ -502,14 +655,18 @@ def _compute_index(
"breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None, "breadth_pct_above_200": round(breadth_pct, 1) if breadth_pct is not None else None,
"breadth_date": breadth_item[0].isoformat() if breadth_item else None, "breadth_date": breadth_item[0].isoformat() if breadth_item else None,
"fundamentals_fetched_at": overrides.get("fetched_at"), "fundamentals_fetched_at": overrides.get("fetched_at"),
"fundamentals_effective_date": fundamental_meta.get("effective_date"), "fundamentals_effective_date": overlay.get("effective_date"),
"fundamentals_age_days": fundamental_meta.get("age_days"), "fundamentals_age_days": overlay.get("age_days"),
}, },
"data_quality": { "data_quality": {
"minimum_coverage": MIN_COVERAGE, "minimum_coverage": MIN_COVERAGE,
"oldest_market_input_age_days": max(source_ages.values()) if source_ages else None, "oldest_market_input_age_days": max(source_ages.values()) if source_ages else None,
"stale_inputs": stale_inputs, "stale_inputs": stale_inputs,
"inputs_fresh": not stale_inputs, "inputs_fresh": not stale_inputs,
# Upstream history spans, so a provider silently truncating a series
# shows up in the record instead of quietly reshaping a sensor.
"credit_history_days": _coverage_days(oas_series, as_of),
"vix_history_days": _coverage_days(vix_series, as_of),
}, },
} }
@@ -581,7 +738,11 @@ async def get_fundamental_overrides(db: AsyncSession) -> dict:
stored = json.loads(raw) stored = json.loads(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
return default return default
if stored.get("methodology") != METHODOLOGY: # The guard rejects pre-v2 blobs, where f1/f3 were arbitrary numbers with no
# categorical source. v2 and v3 share the categorical format and both derive
# f1/f3 from it below, so a methodology bump must not discard a live
# observation -- only the capex *scale* changed, and that is recomputed.
if stored.get("methodology") not in CATEGORICAL_FUNDAMENTAL_METHODOLOGIES:
return default return default
capex = _normalise_capex_states(stored.get("capex"), names) capex = _normalise_capex_states(stored.get("capex"), names)
reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower() reaction = str(stored.get("good_news_stock_down", "mixed")).strip().lower()
@@ -745,7 +906,7 @@ async def _upsert_snapshot(
created_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc),
)) ))
else: else:
existing_v2 = _parse_v2(row.breakdown_json) existing_v2 = _parse_snapshot(row.breakdown_json)
if existing_v2 is not None and not rewrite_existing_v2: if existing_v2 is not None and not rewrite_existing_v2:
return False, existing_v2 return False, existing_v2
row.total_score = float(state_score or 0.0) row.total_score = float(state_score or 0.0)
@@ -754,7 +915,7 @@ async def _upsert_snapshot(
return True, result return True, result
def _parse_v2(raw: str) -> dict | None: def _parse_snapshot(raw: str) -> dict | None:
try: try:
parsed = json.loads(raw) parsed = json.loads(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -762,12 +923,12 @@ def _parse_v2(raw: str) -> dict | None:
return parsed if parsed.get("methodology") == METHODOLOGY else None return parsed if parsed.get("methodology") == METHODOLOGY else None
async def _latest_v2_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None: async def _latest_snapshot_row(db: AsyncSession) -> tuple[RegimeSnapshot, dict] | None:
result = await db.execute( result = await db.execute(
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1000) select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1000)
) )
for row in result.scalars().all(): for row in result.scalars().all():
parsed = _parse_v2(row.breakdown_json) parsed = _parse_snapshot(row.breakdown_json)
if parsed is not None: if parsed is not None:
return row, parsed return row, parsed
return None return None
@@ -791,8 +952,10 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
latest_date = leader_series[-1][0] latest_date = leader_series[-1][0]
vix_series = await _fetch_fred_series("VIXCLS", end - timedelta(days=1200), end) vix_series = await _fetch_fred_series("VIXCLS", end - timedelta(days=1200), end)
# Asking for 13 years was misleading once the licence capped the series at 3;
# the level needs the latest point and W3 needs its lookback, nothing more.
oas_series = await _fetch_fred_series( oas_series = await _fetch_fred_series(
"BAMLH0A0HYM2", end - timedelta(days=int(365.25 * 13)), end "BAMLH0A0HYM2", end - timedelta(days=HY_OAS_WINDOW_DAYS), end
) )
basket = config["breadth_basket"] basket = config["breadth_basket"]
@@ -805,7 +968,7 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc) logger.warning("Regime monitor: fixed-basket breadth skipped: %s", exc)
breadth, breadth_counts, divergence = {}, {}, {} breadth, breadth_counts, divergence = {}, {}, {}
latest_v2 = await _latest_v2_row(db) latest_v2 = await _latest_snapshot_row(db)
rebuilding = latest_v2 is None and bool(leader_series) rebuilding = latest_v2 is None and bool(leader_series)
if rebuilding: if rebuilding:
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]] dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]]
@@ -860,7 +1023,7 @@ async def _result_at_or_before(
.limit(1000) .limit(1000)
) )
for raw in result.scalars().all(): for raw in result.scalars().all():
parsed = _parse_v2(raw) parsed = _parse_snapshot(raw)
parsed_hash = ((parsed or {}).get("basket") or {}).get("hash") parsed_hash = ((parsed or {}).get("basket") or {}).get("hash")
if parsed is not None and (basket_hash is None or parsed_hash == basket_hash): if parsed is not None and (basket_hash is None or parsed_hash == basket_hash):
return parsed return parsed
@@ -877,7 +1040,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
async def get_regime_monitor(db: AsyncSession) -> dict: async def get_regime_monitor(db: AsyncSession) -> dict:
latest = await _latest_v2_row(db) latest = await _latest_snapshot_row(db)
if latest is None: if latest is None:
return {"available": False, "reason": "v2 not computed yet"} return {"available": False, "reason": "v2 not computed yet"}
row, result = latest row, result = latest
@@ -902,6 +1065,15 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
quality["snapshot_age_days"] = snapshot_age quality["snapshot_age_days"] = snapshot_age
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4 quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
result["data_quality"] = quality result["data_quality"] = quality
# The snapshot's overlay is the point-in-time record; the reader also wants
# the current observation even when it is not effective until the next
# session, because otherwise refreshing it looks like it did nothing.
config = await get_regime_config(db)
overrides = await get_fundamental_overrides(db)
live = fundamental_overlay(overrides, config, date.today())
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
result["fundamental_context"] = live
result["available"] = True result["available"] = True
return result return result
@@ -915,7 +1087,7 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]:
) )
out: list[dict] = [] out: list[dict] = []
for row in result.scalars().all(): for row in result.scalars().all():
data = _parse_v2(row.breakdown_json) data = _parse_snapshot(row.breakdown_json)
if data is None: if data is None:
continue continue
state, warning = data.get("state") or {}, data.get("warning") or {} state, warning = data.get("state") or {}, data.get("warning") or {}
-75
View File
@@ -1,75 +0,0 @@
# Regime Monitor v2 methodology
The Regime Monitor is an observational AI/Tech risk thermometer. It does not
gate entries, exits, position size, ranking, or alerts about individual setups.
## Outputs
**State** measures current structural stress:
- Price structure, 40%: `max(P1, P2, P3)`, so the correlated 200-DMA, death-cross,
and drawdown readings receive one capped vote.
- Fixed-basket breadth level, 25%.
- HY option-adjusted credit spread, 20%.
- VIX level, 15%.
**Warning** measures deterioration and divergence:
- Fixed-basket breadth divergence while SMH holds/rises, 50%.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- Hyperscaler capex cuts, 12%.
- Good-news-stock-down earnings reactions, 8%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v2.
## Scale and missing data
Zero means ordinary/healthy, and only stress contributes positively. Automated
capex `raising`/`holding` and no good-news-stock-down pattern map to zero;
`mixed`, unknown, and stale observations are unavailable rather than neutral 50.
Manual observations use the same categories: each hyperscaler is marked
`raising`, `holding`, `cutting`, or `unknown`, while the earnings reaction is
`yes`, `no`, or `mixed`. F1 is derived from the share of at least three known
hyperscalers marked `cutting`; arbitrary numeric overrides are not accepted.
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. Bands are stable `<30`, watch `<60`, elevated `<80`, and
breaking `>=80`.
Credit uses named HY OAS anchors (3.5 mild, 5.0 elevated, 7.0 stressed) for 70%
of its score and a ten-year upper-tail percentile for 30%.
## Point-in-time record
The first v2 run rebuilds the latest 400 trading sessions with sufficient sensor
warm-up. Routine runs thereafter insert/update only the latest trading date.
Fundamental observations have an effective date (normally the next session after
collection) and are never replayed backward. The history API and main chart show
only snapshots marked `methodology: v2`.
Each snapshot stores the fixed basket symbols, hash, and freeze date. Reconstructed
history before that freeze date is retrospective/exploratory; readings after it
form the forward record.
The automatic 400-session rebuild is intentionally one-shot: it runs only when
no v2 snapshot exists. If an initial seed used partial data or the wrong basket,
the operational reseed procedure is to remove the v2 snapshot rows and run the
Regime Monitor job again. There is no routine force-rebuild flag.
## 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%. An alarm requires an upward crossing and another alarm
requires a reset below the threshold. The report exposes warned/missed events,
false alarms per year, median lead, sample dates, event count, report date, and
whether the result is exploratory or a true forward holdout. UI claims are
generated from that report; no performance sentence is hard-coded.
## 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.**
+168
View File
@@ -0,0 +1,168 @@
# Regime Monitor v3 methodology
The Regime Monitor is an observational AI/Tech risk thermometer. It does not
gate entries, exits, position size, ranking, or alerts about individual setups.
v3 supersedes v2. Every parameter below was calibrated against the 408 v2
sessions ending 2026-07-24, reproduced offline from the same Alpaca and FRED
inputs the live job uses; the reproduction matched the stored prod distribution
exactly (State avg 22.6/22.7, p80 35.1, max 91.2, P3 pegged 39, W1 live 108).
## What changed and why
**Fundamentals left the score.** F1 (capex) and F3 (good-news-stock-down)
carried 12 + 8 of 100 Warning points. Pegged at maximum stress they produced a
Warning of exactly 20.0 — below the event study's 25.3 alarm threshold, and
still inside the "stable" band. The sourced observation could not change any
published conclusion, so refreshing it looked like it did nothing. They are now
a qualitative overlay reported beside the scores. Capex also stopped scoring
`raising` and `holding` identically at 0: `holding` is the deceleration case and
now scores 50, so a boom no longer reads the same as a stall.
**The drawdown sensor stopped saturating.** v2 used `dd_pct * 5`, reaching 100 at
a 20% drawdown — the 90th percentile of the observed distribution. 39 of 408
sessions sat at exactly 100 with no resolution left, and the price pillar showed
the top band on 13.5% of sessions. v3 uses named anchors with headroom past the
observed 36% maximum, and blends leader/confirm 2:1 as P1 and P2 already did
instead of taking `max()`. P3's realized share of State falls from 65% to 40%,
matching its nominal weight.
**Warning gained a sensor with range.** The HY OAS *level* is pinned at zero
below the 3.5 mild anchor (2.77 at the cutover), so credit contributed nothing
in a calm tape. Its 20-session rate of change still does, and spread widening is
a classic lead.
**The credit percentile leg was removed.** Its reference window silently shrank
from 10 years to 3 when ICE restricted the upstream series in April 2026, after
which it scored 20 points of stress at a spread the same sensor's anchors call
"mild". See Calibration below.
**Breadth loss counts during declines.** v2's divergence gate was
`price_ret >= 0`, so the sensor zeroed during every selloff. On 2026-07-24 the
basket shed 10 points of participation in 20 sessions while SMH fell 11.9% and
Warning printed exactly 0. v3 tapers to a floor instead: deterioration counts
fully when price masks it (true divergence, the dangerous pre-top case) and at
35% when price confirms it. Breadth *level* lives in State, but breadth
*velocity* appears nowhere else, so this is not double counting.
**Bands are per axis.** v2 Warning never exceeded 64.9 in 408 sessions while
State reached 91.2, yet both used 30/60/80 with quadrant dividers at 60. The
upper half of the Warning axis was unreachable.
## Outputs
**State** — current structural stress:
- Price structure, 40%: `max(P1, P2, P3)`, one capped vote for correlated reads.
- Fixed-basket breadth level, 25%.
- HY option-adjusted credit spread level, 20%.
- VIX level, 15%.
**Warning** — deterioration and divergence:
- Fixed-basket breadth divergence, 45%.
- 60-session SMH/SPY relative-strength deterioration, 30%.
- HY OAS 20-session widening, 25%.
Combined, RSP/SPY (former F4), and the NVDA canary (former P6) do not enter v3.
## Calibration
P3 drawdown anchors, as (drawdown %, score): 0→0, 4→10, 8→25, 16→50, 28→78,
40→100, flat outside. Credit impulse is relative (+35% over 20 sessions = 100)
rather than absolute, because +0.5pp means something very different at an OAS of
2.7 than at 8.0.
Bands are round, meaning-anchored numbers, not percentile fits — percentile
thresholds would drift on every rebuild and silently rewrite what past snapshots
meant. Realized shares over the calibration window:
| Axis | stable | watch | elevated | breaking | thresholds |
|------|--------|-------|----------|----------|------------|
| State | 73.3% | 15.0% | 8.3% | 3.4% | 20 / 50 / 80 |
| Warning | 69.4% | 19.6% | 7.6% | 3.4% | 20 / 40 / 60 |
Quadrant dividers sit at each axis's watch/elevated boundary: State 50,
Warning 40.
Scores renormalize over available fixed weights, but a band is published only at
75% or greater coverage. Trend deltas are suppressed when the participating
pillar set changes. Zero means ordinary/healthy; only stress contributes.
Credit level is the named HY OAS anchors alone: 3.5 mild, 5.0 elevated, 7.0
stressed, linear between, and nothing else. v2 blended those anchors at 70% with
a 30% upper-tail percentile over a nominally 10-year window.
That leg was removed rather than repaired. ICE restricted FRED to a rolling
3-year window for `BAMLH0A0HYM2` in April 2026 — the series metadata states it
outright ("Starting in April 2026, this series will only include 3 years of
observations"), and an unbounded request returns the same 795 observations as a
30-year one. The v2 percentile therefore ranked the current spread against three
uniformly tight years (range 2.594.61 over the calibration window), which made
it fire early and saturate absurdly: at an OAS of 3.50 — the level the anchors
call *mild*, scoring zero stress — the blended sensor read 20.1, and the
percentile leg pegged at 100 by an OAS of 4.5. Across the 408 sessions it
roughly tripled the credit sensor's average (2.70 vs 1.00) and more than doubled
its nonzero days (60 vs 27).
The anchors already encode the long-run distribution as constants, so the
percentile was a second, noisier estimate of the same thing. What it was
genuinely reaching for — "unusual versus recent history" — is now W3 on the
Warning axis, computed as a rate of change, which is where deterioration
belongs. Removing it moved State's average by 0.4 and its maximum by 3.8, left
Warning bit-identical, and did not shift any band threshold.
A long-history alternative (`BAA10Y`, Fed-published, 7,712 observations back to
1997) was considered and rejected: ranking an HY spread against investment-grade
history is not a coherent statistic, and it would rescue a leg that is redundant
anyway.
Every snapshot now records `data_quality.credit_history_days` and
`vix_history_days`. This defect was invisible for roughly three months because
nothing asserted the window the code claimed; the spans make a future upstream
truncation show up in the record instead of quietly reshaping a sensor.
**Survivorship caveat.** The basket was frozen 2026-07-15 but the calibration
window reaches back to 2024, so names were partly selected for having done well.
Every distribution above inherits that bias. It is the same bias v2 carried, so
the v2/v3 comparison is like-for-like, but the absolute band shares are
optimistic.
## Point-in-time record
The first run under a new `METHODOLOGY` rebuilds the latest 400 trading sessions
with sufficient sensor warm-up; routine runs thereafter insert/update only the
latest trading date. The history API and main chart show only snapshots matching
the current methodology, so a bump reseeds the series rather than splicing two
formulas into one line.
The fundamental overlay keeps its effective date (normally the next session after
collection) and is never replayed backward, so a rebuild cannot stamp today's
observation onto historical snapshots. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot
therefore reports the overlay as `pending` until the new effective date, and the
live reading additionally carries `fundamental_context` so a just-collected
observation is visible immediately rather than appearing to have done nothing.
Each snapshot stores the fixed basket symbols, hash, and freeze date.
Reconstructed history before that freeze date is retrospective/exploratory.
## 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.**
## 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.**
+25 -1
View File
@@ -480,6 +480,9 @@ export interface RegimePillar {
export interface RegimeReading { export interface RegimeReading {
score: number | null; score: number | null;
band: RegimeBand | null; band: RegimeBand | null;
// Per axis: State and Warning have different realized ranges, so they do not
// share thresholds.
bands?: { watch: number; elevated: number; breaking: number };
coverage: number; coverage: number;
minimum_coverage: number; minimum_coverage: number;
available_pillars: string[]; available_pillars: string[];
@@ -487,6 +490,23 @@ export interface RegimeReading {
trend?: { delta_7: number | null; delta_30: number | null }; trend?: { delta_7: number | null; delta_30: number | null };
} }
/** Qualitative capex / earnings-reaction context. Not part of either score. */
export interface RegimeFundamentalOverlay {
available: boolean;
pending: boolean;
stale: boolean;
effective_date: string | null;
age_days: number | null;
capex: Record<string, CapexState> | null;
good_news_stock_down: GoodNewsReaction | null;
capex_stress: number | null;
earnings_stress: number | null;
reasoning: string | null;
source: string | null;
fetched_at: string | null;
observed_in_snapshot?: boolean;
}
export interface RegimeHistoryPoint { export interface RegimeHistoryPoint {
date: string; date: string;
state: number | null; state: number | null;
@@ -503,6 +523,10 @@ export interface RegimeMonitor {
date?: string; date?: string;
state?: RegimeReading; state?: RegimeReading;
warning?: RegimeReading; warning?: RegimeReading;
/** Point-in-time overlay recorded in the snapshot. */
fundamental_overlay?: RegimeFundamentalOverlay;
/** Current observation, even when it is not effective until the next session. */
fundamental_context?: RegimeFundamentalOverlay;
inputs?: { inputs?: {
vix: number | null; vix: number | null;
vix_date: string | null; vix_date: string | null;
@@ -534,7 +558,7 @@ export interface RegimeMonitor {
} }
export interface RegimeFundamentals { export interface RegimeFundamentals {
methodology: 'v2'; methodology: 'v3';
f1_score: number | null; f1_score: number | null;
f3_score: number | null; f3_score: number | null;
locked: boolean; locked: boolean;
+90 -6
View File
@@ -21,6 +21,7 @@ import type {
GoodNewsReaction, GoodNewsReaction,
RegimeBand, RegimeBand,
RegimeConfig, RegimeConfig,
RegimeFundamentalOverlay,
RegimeFundamentals, RegimeFundamentals,
RegimeFundamentalsUpdate, RegimeFundamentalsUpdate,
RegimeReading, RegimeReading,
@@ -64,6 +65,8 @@ function ScoreGauge({
const complete = reading?.band != null; const complete = reading?.band != null;
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null; const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
const position = Math.min(100, Math.max(0, score ?? 0)); const position = Math.min(100, Math.max(0, score ?? 0));
const bands = reading?.bands;
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80];
return ( return (
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}> <div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
@@ -98,8 +101,15 @@ function ScoreGauge({
style={{ left: `${position}%` }} style={{ left: `${position}%` }}
/> />
</div> </div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600"> {/* Thresholds come from the reading: the two axes no longer share them. */}
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span> <div className="relative mt-1.5 h-4 text-[10px] uppercase tracking-wider text-gray-600">
<span className="absolute left-0">0</span>
{ticks.map((tick) => (
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
{tick}
</span>
))}
<span className="absolute right-0">100</span>
</div> </div>
</> </>
)} )}
@@ -108,6 +118,77 @@ function ScoreGauge({
); );
} }
const CAPEX_TONE: Record<CapexState, string> = {
raising: 'text-emerald-400',
holding: 'text-amber-400',
cutting: 'text-red-400',
unknown: 'text-gray-500',
};
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
const capex = overlay.capex ?? {};
const reaction = overlay.good_news_stock_down;
return (
<div className="glass border border-white/[0.06] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
Fundamental overlay · context, not scored
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
{overlay.source && <span>{overlay.source}</span>}
{overlay.effective_date && <span>· effective {overlay.effective_date}</span>}
{overlay.pending && <Badge label="pending" variant="manual" />}
{overlay.stale && <Badge label="stale" variant="manual" />}
</div>
</div>
{overlay.pending ? (
<p className="mt-3 text-xs leading-relaxed text-amber-400/90">
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}.
Observations are never backdated, so the reading below appears from that session onward.
</p>
) : (
<>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
</div>
<div className="space-y-1">
{Object.entries(capex).map(([symbol, state]) => (
<div key={symbol} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-400">{symbol}</span>
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
</div>
))}
</div>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Good news, stock down</span>
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
</div>
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
</div>
</div>
</div>
{overlay.reasoning && (
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
)}
</>
)}
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
Warning. In v2 they carried 20 of 100 Warning points not enough to cross the study's alarm threshold even
when both were pegged — so they are reported here rather than diluted into a daily score.
</p>
</div>
);
}
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) { function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) {
return ( return (
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}> <Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
@@ -235,8 +316,10 @@ function FundamentalsEditor({
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex })); const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down); const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown'); const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
const cutting = knownCapex.filter((state) => state === 'cutting').length; // Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
const derivedF1 = knownCapex.length >= 3 ? Math.round((cutting / knownCapex.length) * 1000) / 10 : null; // the deceleration case and used to score identically to raising.
const capexPoints = knownCapex.reduce((sum, state) => sum + (state === 'cutting' ? 100 : state === 'holding' ? 50 : 0), 0);
const derivedF1 = knownCapex.length >= 3 ? Math.round((capexPoints / knownCapex.length) * 10) / 10 : null;
const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null; const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -266,7 +349,7 @@ function FundamentalsEditor({
</label> </label>
))} ))}
</div> </div>
<p className="mt-1.5 text-[11px] text-gray-600">Raising/holding = 0 stress; cutting = 100; at least three known names required.</p> <p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required. Display only — this does not enter Warning.</p>
</div> </div>
<label className="flex items-center justify-between gap-3 text-xs text-gray-400"> <label className="flex items-center justify-between gap-3 text-xs text-gray-400">
<span> <span>
@@ -369,9 +452,10 @@ export default function RegimePage() {
label="Warning · deterioration & divergence" label="Warning · deterioration & divergence"
reading={data.warning} reading={data.warning}
divider={data.quadrant_config?.warning_divider} divider={data.quadrant_config?.warning_divider}
footnote={<>Breadth divergence, SMH/SPY rollover, and point-in-time fundamental observations. Unknown or stale fundamentals reduce coverage; they never default to 50.</>} footnote={<>Breadth divergence, SMH/SPY rollover, and HY credit impulse. Breadth loss counts fully when price masks it and partially when price confirms it. Missing sensors reduce coverage; they never default to 50.</>}
/> />
</div> </div>
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
Data quality · oldest market input:{' '} Data quality · oldest market input:{' '}
{data.data_quality?.oldest_market_input_age_days == null {data.data_quality?.oldest_market_input_age_days == null
+10 -3
View File
@@ -52,7 +52,7 @@ def test_evaluate_alarms_counts_episodes_not_alarm_days():
assert result["median_lead_days"] == 17.5 assert result["median_lead_days"] == 17.5
def test_breadth_from_fixed_closes_and_pure_divergence(): def test_breadth_from_fixed_closes_and_tapered_divergence():
dates = _days(10) dates = _days(10)
closes_by_symbol = { closes_by_symbol = {
"A": list(zip(dates, [1.0 + index for index in range(10)])), "A": list(zip(dates, [1.0 + index for index in range(10)])),
@@ -67,6 +67,13 @@ def test_breadth_from_fixed_closes_and_pure_divergence():
divergence = compute_divergence_series(falling_breadth, rising_benchmark, lookback=3) divergence = compute_divergence_series(falling_breadth, rising_benchmark, lookback=3)
assert divergence[dates[-1]] > 0 assert divergence[dates[-1]] > 0
# v3: breadth loss with price confirming it is still deterioration, scored at
# DIVERGENCE_CONFIRMED_FLOOR of the masked case rather than discarded. v2's
# hard gate zeroed this and left Warning at 0 through every selloff.
falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)])) falling_benchmark = list(zip(dates, [100.0 - index for index in range(10)]))
no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3) confirmed = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
assert no_divergence[dates[-1]] == 0 assert 0 < confirmed[dates[-1]] < divergence[dates[-1]]
# Flat breadth is not deterioration regardless of price direction.
flat_breadth = {day: 60.0 for day in dates}
assert compute_divergence_series(flat_breadth, falling_benchmark, lookback=3)[dates[-1]] == 0
+227 -40
View File
@@ -1,4 +1,4 @@
"""Pure-function tests for the v2 Regime Monitor contract.""" """Pure-function tests for the v3 Regime Monitor contract."""
from __future__ import annotations from __future__ import annotations
@@ -12,23 +12,30 @@ from sqlalchemy import select
from app.models.regime_snapshot import RegimeSnapshot from app.models.regime_snapshot import RegimeSnapshot
from app.routers import market as market_router from app.routers import market as market_router
from app.services import regime_monitor_service as rms from app.services import breadth_service, regime_monitor_service as rms
from app.services.regime_monitor_service import ( from app.services.regime_monitor_service import (
DEFAULT_CONFIG, DEFAULT_CONFIG,
HY_OAS_ELEVATED, HY_OAS_ELEVATED,
HY_OAS_MILD, HY_OAS_MILD,
HY_OAS_STRESSED, HY_OAS_STRESSED,
STATE_BANDS,
WARNING_BANDS,
WARNING_WEIGHTS,
_compute_index, _compute_index,
_fundamental_scores_asof,
_score_pillars, _score_pillars,
band_for, band_for,
breadth_level_score, breadth_level_score,
drawdown_pct,
f2_credit_spreads, f2_credit_spreads,
fundamental_overlay,
p1_trend_break, p1_trend_break,
p2_death_cross, p2_death_cross,
p3_drawdown, p3_drawdown,
p4_relative_strength, p4_relative_strength,
p5_volatility, p5_volatility,
score_warning_sensors,
w3_credit_impulse,
warning_sensor_scores,
) )
@@ -39,11 +46,15 @@ def _dated(values: list[float], end: date = date(2026, 6, 26)) -> list[tuple[dat
] ]
def test_band_for_keeps_documented_boundaries(): def test_band_for_is_per_axis():
assert band_for(10) == "stable" assert band_for(10, STATE_BANDS) == "stable"
assert band_for(30) == "watch" assert band_for(20, STATE_BANDS) == "watch"
assert band_for(60) == "elevated" assert band_for(50, STATE_BANDS) == "elevated"
assert band_for(80) == "breaking" assert band_for(80, STATE_BANDS) == "breaking"
# Warning's realized range is far narrower, so it gets its own thresholds.
assert band_for(45, STATE_BANDS) == "watch"
assert band_for(45, WARNING_BANDS) == "elevated"
assert band_for(60, WARNING_BANDS) == "breaking"
def test_price_sensors_are_stress_only(): def test_price_sensors_are_stress_only():
@@ -56,8 +67,97 @@ def test_price_sensors_are_stress_only():
assert (p2_death_cross(bearish, bearish) or 0) > 0 assert (p2_death_cross(bearish, bearish) or 0) > 0
assert p2_death_cross(healthy, healthy) == 0 assert p2_death_cross(healthy, healthy) == 0
closes = [100.0] * 252 + [80.0]
assert p3_drawdown(closes, [100.0] * 253) == 100.0 def test_drawdown_sensor_keeps_headroom_past_a_twenty_percent_fall():
"""v2 pegged at 100 on a 20% drawdown, losing all resolution deeper in."""
flat = [100.0] * 253
down_20 = [100.0] * 252 + [80.0]
down_30 = [100.0] * 252 + [70.0]
down_45 = [100.0] * 252 + [55.0]
assert drawdown_pct(down_20) == pytest.approx(20.0)
leader_only_20 = p3_drawdown(down_20, flat)
leader_only_30 = p3_drawdown(down_30, flat)
assert leader_only_20 < leader_only_30 < 100.0
# Full scale needs both legs at the deepest anchor, not one at 20%.
assert p3_drawdown(down_45, down_45) == 100.0
assert p3_drawdown(flat, flat) == 0.0
def test_drawdown_blends_leader_and_confirm_instead_of_taking_the_max():
"""max() let the more volatile leader own the whole price pillar."""
flat = [100.0] * 253
down = [100.0] * 252 + [72.0]
both = p3_drawdown(down, down)
leader_only = p3_drawdown(down, flat)
assert leader_only == pytest.approx(both * 2.0 / 3.0)
def test_credit_impulse_scores_widening_only():
assert w3_credit_impulse([3.0] * 40) == 0.0
# Tightening is not stress.
assert w3_credit_impulse([4.0] * 21 + [3.0]) == 0.0
# +35% over the lookback is full scale; half of it is half the score.
assert w3_credit_impulse([3.0] * 21 + [3.0 * 1.35]) == pytest.approx(100.0)
assert w3_credit_impulse([3.0] * 21 + [3.0 * 1.175]) == pytest.approx(50.0)
# Fires while the OAS *level* is still far below the 3.5 mild anchor. This
# is the pairing that lets the level stay purely anchored: dynamics live on
# the Warning axis rather than being smuggled into State as a percentile.
assert f2_credit_spreads([2.0] * 21 + [2.7]) == 0.0
assert (w3_credit_impulse([2.0] * 21 + [2.7]) or 0) > 0
assert w3_credit_impulse([3.0] * 5) is None
def test_snapshot_records_upstream_history_spans():
"""Guards the silent-truncation failure mode that caused this change."""
end = date(2026, 6, 26)
rising = [100.0 + index * 0.2 for index in range(700)]
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
oas = [(end - timedelta(days=index), 4.0) for index in reversed(range(100))]
result = _compute_index(
prices, [(end, 20.0)], oas, {"f1_score": None, "f3_score": None},
copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25},
)
assert result["data_quality"]["credit_history_days"] == 99
assert result["data_quality"]["vix_history_days"] == 0
def test_divergence_still_registers_when_price_confirms_the_breadth_loss():
"""v2's hard price gate zeroed this sensor during every decline.
On 2026-07-24 the basket shed 10 points of participation in 20 sessions
while SMH fell 11.9%, and Warning printed exactly 0 as a result.
"""
days = [date(2026, 1, 1) + timedelta(days=index) for index in range(21)]
breadth = {day: 70.0 for day in days[:1]} | {day: 70.0 - index for index, day in enumerate(days)}
holding = [(day, 100.0) for day in days]
falling = [(day, 100.0 - index * 0.9) for index, day in enumerate(days)]
masked = breadth_service.compute_divergence_series(breadth, holding)[days[-1]]
confirmed = breadth_service.compute_divergence_series(breadth, falling)[days[-1]]
assert masked > confirmed > 0
assert confirmed == pytest.approx(masked * breadth_service.DIVERGENCE_CONFIRMED_FLOOR)
def test_warning_score_renormalises_over_available_sensors():
full = {"breadth_divergence": 40.0, "relative_strength": 0.0, "credit_impulse": 20.0}
assert score_warning_sensors(full) == pytest.approx(
(40 * 45 + 0 * 30 + 20 * 25) / 100
)
partial = {"breadth_divergence": 40.0, "relative_strength": None, "credit_impulse": None}
assert score_warning_sensors(partial) == 40.0
assert score_warning_sensors(dict.fromkeys(full, None)) is None
def test_warning_sensor_scores_covers_every_weighted_pillar():
"""Guards the study/monitor shared definition against silent drift."""
sensors = warning_sensor_scores(10.0, [100.0] * 70, [100.0] * 70, [3.0] * 40)
assert set(sensors) == set(WARNING_WEIGHTS)
def test_relative_strength_flat_or_better_is_zero(): def test_relative_strength_flat_or_better_is_zero():
@@ -77,12 +177,24 @@ def test_volatility_and_breadth_zero_points():
assert breadth_level_score(None) is None assert breadth_level_score(None) is None
def test_credit_uses_named_anchors_and_constant_series_is_not_extreme(): def test_credit_level_is_anchored_and_ignores_the_reference_window():
assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0 """The percentile leg is gone: the anchors already encode the long run.
assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 35.0
assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 70.0 It ranked the level against whatever history the upstream series happened to
rising = [3.0 + index * 0.01 for index in range(100)] serve, and that silently shrank from 10 years to 3 in April 2026 -- three
assert (f2_credit_spreads(rising) or 0) > f2_credit_spreads([3.0] * 100) uniformly tight years, against which an unremarkable spread scored as an
extreme. Identical inputs must now score identically regardless of window.
"""
assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0.0
assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 50.0
assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 100.0
assert f2_credit_spreads([]) is None
# A level at the "mild" anchor is zero stress even when it tops its window.
tight_window = [2.6] * 400 + [HY_OAS_MILD]
assert f2_credit_spreads(tight_window) == 0.0
# Only the latest observation matters; history cannot move the reading.
assert f2_credit_spreads([9.0] * 400 + [3.0]) == f2_credit_spreads([2.6] * 400 + [3.0])
def test_score_pillars_gates_band_below_75_percent_coverage(): def test_score_pillars_gates_band_below_75_percent_coverage():
@@ -98,31 +210,75 @@ def test_score_pillars_gates_band_below_75_percent_coverage():
assert result["band"] is None assert result["band"] is None
def test_fundamentals_never_replay_before_effective_date_and_expire(): def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
overrides = { overrides = {
"f1_score": 0.0, "f1_score": 0.0,
"f3_score": 100.0, "f3_score": 100.0,
"capex": {"GOOGL": "raising"},
"good_news_stock_down": "yes",
"fetched_at": "2026-06-01T10:00:00+00:00", "fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02", "effective_date": "2026-06-02",
} }
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80} config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 1))[:2] == (None, None)
assert _fundamental_scores_asof(overrides, config, date(2026, 6, 2))[:2] == (0.0, 100.0) pending = fundamental_overlay(overrides, config, date(2026, 6, 1))
assert _fundamental_scores_asof(overrides, config, date(2026, 8, 22))[:2] == (None, None) assert pending["pending"] is True
assert pending["available"] is False
assert pending["capex"] is None
# The effective date is still reported so a pending refresh is visible.
assert pending["effective_date"] == "2026-06-02"
live = fundamental_overlay(overrides, config, date(2026, 6, 2))
assert live["available"] is True
assert live["good_news_stock_down"] == "yes"
assert live["earnings_stress"] == 100.0
expired = fundamental_overlay(overrides, config, date(2026, 8, 22))
assert expired["stale"] is True
assert expired["available"] is False
def test_capex_score_is_derived_from_company_categories(): def test_fundamentals_do_not_move_the_warning_score():
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.
It no longer feeds Warning at all, so Warning is identical either way and
the observation is reported beside the score instead of buried in it.
"""
end = date(2026, 6, 26)
rising = [100.0 + index * 0.2 for index in range(700)]
prices = {"SMH": _dated(rising, end), "QQQ": _dated(rising, end), "SPY": _dated(rising, end)}
args = (prices, [(end, 20.0)], [(end - timedelta(days=i), 4.0) for i in reversed(range(100))])
tail = (copy.deepcopy(DEFAULT_CONFIG), end, [(end, 55.0)], [(end, 20.0)], {end: 25})
quiet = _compute_index(*args, {"f1_score": None, "f3_score": None}, *tail)
screaming = _compute_index(
*args,
{
"f1_score": 100.0,
"f3_score": 100.0,
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
"good_news_stock_down": "yes",
"effective_date": "2026-06-01",
},
*tail,
)
assert quiet["warning"]["score"] == screaming["warning"]["score"]
assert {p["id"] for p in quiet["warning"]["pillars"]} == set(WARNING_WEIGHTS)
assert screaming["fundamental_overlay"]["available"] is True
assert screaming["fundamental_overlay"]["capex_stress"] == 100.0
def test_capex_score_separates_holding_from_raising():
"""v2 mapped raising and holding both to 0, so a boom read identical to a
deceleration and the sensor carried no information."""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"] names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
assert rms._score_capex_states(dict.fromkeys(names, "raising"), names) == 0.0
assert rms._score_capex_states(dict.fromkeys(names, "holding"), names) == 50.0
assert rms._score_capex_states(dict.fromkeys(names, "cutting"), names) == 100.0
assert rms._score_capex_states( assert rms._score_capex_states(
dict.fromkeys(names, "holding"), names {names[0]: "raising", **dict.fromkeys(names[1:], "holding")}, names
) == 0.0 ) == 37.5
assert rms._score_capex_states(
{names[0]: "cutting", **dict.fromkeys(names[1:], "holding")}, names
) == 25.0
assert rms._score_capex_states(
{names[0]: "cutting", names[1]: "holding", names[2]: "holding", names[3]: "unknown"},
names,
) == 33.3
assert rms._score_capex_states( assert rms._score_capex_states(
{names[0]: "cutting", names[1]: "holding", names[2]: "unknown", names[3]: "unknown"}, {names[0]: "cutting", names[1]: "holding", names[2]: "unknown", names[3]: "unknown"},
names, names,
@@ -135,7 +291,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_v2(monkeypatch): async def test_legacy_numeric_fundamentals_do_not_leak_into_v3(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"})
@@ -143,16 +299,47 @@ async def test_legacy_numeric_fundamentals_do_not_leak_into_v2(monkeypatch):
result = await rms.get_fundamental_overrides(object()) result = await rms.get_fundamental_overrides(object())
assert result["methodology"] == "v2" assert result["methodology"] == "v3"
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
async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
"""A snapshot reseed must not throw away a hand/LLM-collected observation.
The categorical format is unchanged, so the stored capex map is still valid;
only the capex scale moved, and f1 is recomputed from the categories.
"""
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
async def fake_value(_db, _key):
return json.dumps({
"methodology": "v2",
"f1_score": 0.0, # stale v2 scale, must be recomputed
"f3_score": 100.0,
"capex": {names[0]: "raising", **dict.fromkeys(names[1:], "holding")},
"good_news_stock_down": "yes",
"source": "gemini",
"fetched_at": "2026-07-24T14:25:47+00:00",
"effective_date": "2026-07-27",
})
monkeypatch.setattr(rms.settings_store, "get_value", fake_value)
result = await rms.get_fundamental_overrides(object())
assert result["source"] == "gemini"
assert result["good_news_stock_down"] == "yes"
assert result["effective_date"] == "2026-07-27"
assert result["f1_score"] == 37.5 # recomputed on the v3 scale, not the stored 0.0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch): async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
stored = { stored = {
"methodology": "v2", "methodology": "v3",
"f1_score": 100.0, "f1_score": 100.0,
"f3_score": 0.0, "f3_score": 0.0,
"capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"), "capex": dict.fromkeys(DEFAULT_CONFIG["tickers"]["hyperscalers"], "cutting"),
@@ -185,7 +372,7 @@ async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch): async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
names = DEFAULT_CONFIG["tickers"]["hyperscalers"] names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
current = { current = {
"methodology": "v2", "methodology": "v3",
"f1_score": None, "f1_score": None,
"f3_score": None, "f3_score": None,
"capex": dict.fromkeys(names, "unknown"), "capex": dict.fromkeys(names, "unknown"),
@@ -212,7 +399,7 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
object(), capex=capex, good_news_stock_down="mixed" object(), capex=capex, good_news_stock_down="mixed"
) )
assert result["f1_score"] == 25.0 assert result["f1_score"] == 62.5 # one cutting (100) + three holding (50)
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"
assert result["source"] == "manual" assert result["source"] == "manual"
@@ -222,10 +409,10 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_prior_v2_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": "v2", "methodology": "v3",
"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"},
@@ -281,7 +468,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": "v2"} return object(), {"methodology": "v3"}
async def fake_upsert(_db, result, *, rewrite_existing_v2): async def fake_upsert(_db, result, *, rewrite_existing_v2):
rewrites.append(rewrite_existing_v2) rewrites.append(rewrite_existing_v2)
@@ -296,7 +483,7 @@ async def test_routine_can_refresh_latest_trading_session_after_civil_day_rolls(
monkeypatch.setattr(rms, "_fetch_prices", fake_prices) monkeypatch.setattr(rms, "_fetch_prices", fake_prices)
monkeypatch.setattr(rms, "_fetch_fred_series", fake_fred) monkeypatch.setattr(rms, "_fetch_fred_series", fake_fred)
monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth) monkeypatch.setattr(rms.breadth_service, "compute_breadth_details", fake_breadth)
monkeypatch.setattr(rms, "_latest_v2_row", fake_latest) monkeypatch.setattr(rms, "_latest_snapshot_row", fake_latest)
monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert) monkeypatch.setattr(rms, "_upsert_snapshot", fake_upsert)
result = await rms.update_regime_monitor(FakeDB()) result = await rms.update_regime_monitor(FakeDB())
@@ -364,6 +551,6 @@ 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"] == "v2" assert result["methodology"] == "v3"
assert "combined" not in result assert "combined" not in result
assert result["basket"]["members_available"] == 25 assert result["basket"]["members_available"] == 25
+21 -11
View File
@@ -1,36 +1,46 @@
"""Tests for v2 State/Warning quadrant hysteresis and basket reseeding keys.""" """Tests for v3 State/Warning quadrant hysteresis and basket reseeding keys.
v3 dividers are per axis (State 50, Warning 40) because the two scores have
different realized ranges -- Warning never exceeded 64.9 in the 408 calibration
sessions, so a shared 60 left the whole upper half of that axis unreachable.
"""
from app.services.alert_service import ( from app.services.alert_service import (
QUAD_X_DIV,
QUAD_Y_DIV,
_classify_quadrant, _classify_quadrant,
_parse_quadrant_log_key, _parse_quadrant_log_key,
_quadrant_log_key, _quadrant_log_key,
) )
def test_fresh_classification_uses_60_60_boundaries(): def test_fresh_classification_uses_per_axis_boundaries():
assert (QUAD_X_DIV, QUAD_Y_DIV) == (50.0, 40.0)
assert _classify_quadrant(20, 90, None) == "1" assert _classify_quadrant(20, 90, None) == "1"
assert _classify_quadrant(70, 90, None) == "2" assert _classify_quadrant(70, 90, None) == "2"
assert _classify_quadrant(20, 30, None) == "3" assert _classify_quadrant(20, 30, None) == "3"
assert _classify_quadrant(70, 30, None) == "4" assert _classify_quadrant(70, 30, None) == "4"
# A Warning of 45 is above its own divider but below State's.
assert _classify_quadrant(45, 45, None) == "1"
def test_warning_axis_hysteresis(): def test_warning_axis_hysteresis():
assert _classify_quadrant(20, 62, prev="3") == "3" assert _classify_quadrant(20, 42, prev="3") == "3"
assert _classify_quadrant(20, 66, prev="3") == "1" assert _classify_quadrant(20, 46, prev="3") == "1"
assert _classify_quadrant(20, 58, prev="1") == "1" assert _classify_quadrant(20, 38, prev="1") == "1"
assert _classify_quadrant(20, 54, prev="1") == "3" assert _classify_quadrant(20, 34, prev="1") == "3"
def test_state_axis_hysteresis(): def test_state_axis_hysteresis():
assert _classify_quadrant(63, 30, prev="3") == "3" assert _classify_quadrant(53, 30, prev="3") == "3"
assert _classify_quadrant(66, 30, prev="3") == "4" assert _classify_quadrant(56, 30, prev="3") == "4"
assert _classify_quadrant(57, 30, prev="4") == "4" assert _classify_quadrant(47, 30, prev="4") == "4"
assert _classify_quadrant(54, 30, prev="4") == "3" assert _classify_quadrant(44, 30, prev="4") == "3"
def test_boundary_sitting_does_not_flip(): def test_boundary_sitting_does_not_flip():
for quadrant in ("1", "2", "3", "4"): for quadrant in ("1", "2", "3", "4"):
assert _classify_quadrant(60, 60, prev=quadrant) == quadrant assert _classify_quadrant(QUAD_X_DIV, QUAD_Y_DIV, prev=quadrant) == quadrant
def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys(): def test_quadrant_key_carries_basket_hash_and_parses_legacy_keys():