Rewrite Regime Monitor as v3: fundamentals off the score, desaturate P3
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:
@@ -133,7 +133,7 @@ indicators.
|
||||
|
||||
1. **OHLCV** — latest daily bars (Alpaca); new tickers backfill ~5 years.
|
||||
2. **Sentiment** — stale names that matter (top-pick feeders, watchlist, open paper, discovery net). Display context only; the activation gate is price-only.
|
||||
3. **Market 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.
|
||||
|
||||
**Near-close** (~15:30 ET Mon–Fri) — 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)
|
||||
- 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
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
# from flip-flopping; the cooldown caps how often a genuine change can re-alert.
|
||||
QUAD_TYPE = "regime_quadrant"
|
||||
QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative)
|
||||
QUAD_Y_DIV = 60.0 # v2 Warning divider
|
||||
QUAD_X_DIV = 50.0 # v3 State divider (backend response is authoritative)
|
||||
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_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
|
||||
QUAD_LABELS = {
|
||||
|
||||
@@ -72,15 +72,25 @@ def _breadth_from_closes(
|
||||
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(
|
||||
breadth: dict[date, float], benchmark_closes: Series, lookback: int = 20
|
||||
) -> dict[date, float]:
|
||||
"""Early-warning score (0-100, high = fragile) per date.
|
||||
|
||||
This is deliberately a pure divergence: it is positive only when benchmark
|
||||
price holds/rises while breadth falls. Absolute low breadth belongs in the
|
||||
State score, so it is not counted again here. A 20 percentage-point breadth
|
||||
deterioration maps to 100.
|
||||
A 20 percentage-point breadth deterioration maps to 100 when the benchmark
|
||||
is flat or rising, tapering to ``DIVERGENCE_CONFIRMED_FLOOR`` of that once
|
||||
the benchmark is down ``DIVERGENCE_TAPER_PCT`` or more over the window.
|
||||
"""
|
||||
bench = {d: c for d, c in benchmark_closes}
|
||||
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 # %
|
||||
breadth_chg = breadth[d] - breadth[d0] # percentage points
|
||||
deterioration = max(0.0, -breadth_chg)
|
||||
score = deterioration * 5.0 if price_ret >= 0 else 0.0
|
||||
out[d] = max(0.0, min(100.0, round(score, 2)))
|
||||
taper = max(0.0, min(1.0, (price_ret + DIVERGENCE_TAPER_PCT) / DIVERGENCE_TAPER_PCT))
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -149,29 +149,29 @@ def _warning_series(
|
||||
breadth_divergence: dict[date, float],
|
||||
dates: list[date],
|
||||
config: dict,
|
||||
oas_series: rms.Series | None = None,
|
||||
) -> 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"]
|
||||
smh_full = prices.get(tickers["leaders"][0], [])
|
||||
spy_full = prices.get(tickers["market"], [])
|
||||
out: dict[date, float] = {}
|
||||
for session in dates:
|
||||
divergence = breadth_divergence.get(session)
|
||||
relative = rms.p4_relative_strength(
|
||||
sensors = rms.warning_sensor_scores(
|
||||
breadth_divergence.get(session),
|
||||
rms._closes_asof(smh_full, session),
|
||||
rms._closes_asof(spy_full, session),
|
||||
rms._window_asof(oas_series, session, rms.HY_OAS_WINDOW_DAYS),
|
||||
)
|
||||
values: list[tuple[float, float]] = []
|
||||
if divergence is not None:
|
||||
values.append((divergence, rms.WARNING_WEIGHTS["breadth_divergence"]))
|
||||
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,
|
||||
)
|
||||
score = rms.score_warning_sensors(sensors)
|
||||
if score is not None:
|
||||
out[session] = round(score, 2)
|
||||
return out
|
||||
|
||||
|
||||
@@ -195,7 +195,13 @@ async def run_event_study(
|
||||
db, config["breadth_basket"], window=200, min_tickers=20
|
||||
)
|
||||
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)))
|
||||
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,
|
||||
"warn_percentile": WARN_PERCENTILE,
|
||||
"warn_threshold": round(warn_threshold, 1),
|
||||
"credit_sensor_from": credit_from,
|
||||
"basket_hash": rms._basket_hash(config["breadth_basket"]),
|
||||
"basket_asof": config["basket_asof"],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
two deliberately separate outputs:
|
||||
|
||||
* State: current structural stress (price, breadth, credit, volatility).
|
||||
* Warning: deterioration/divergence that may precede State (breadth, relative
|
||||
strength, and sourced fundamental observations).
|
||||
* Warning: deterioration/divergence that may precede State (breadth divergence,
|
||||
relative strength, credit impulse).
|
||||
|
||||
Daily snapshots are the point-in-time record. The first v2 run rewrites the
|
||||
latest ``REBUILD_SESSIONS`` trading sessions once; ordinary runs thereafter only
|
||||
upsert the latest trading date. Fundamental observations are never replayed
|
||||
before their effective date.
|
||||
Both scores are quantitative and daily. The sourced hyperscaler capex and
|
||||
earnings-reaction observations are a qualitative *overlay* in v3 rather than
|
||||
weighted sensors: at a combined 20 points they could not reach the event
|
||||
study's alarm threshold even when both pegged, so refreshing them appeared to
|
||||
do nothing. They are reported next to the scores instead of inside them.
|
||||
|
||||
Daily snapshots are the point-in-time record. The first run under a new
|
||||
``METHODOLOGY`` rewrites the latest ``REBUILD_SESSIONS`` trading sessions once;
|
||||
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
|
||||
@@ -41,19 +48,51 @@ _CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
|
||||
KEY_CONFIG = "regime_monitor_config"
|
||||
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
|
||||
MIN_COVERAGE = 75.0
|
||||
SOURCE_MAX_LAG_DAYS = 7
|
||||
|
||||
QUADRANT_STATE_DIVIDER = 60.0
|
||||
QUADRANT_WARNING_DIVIDER = 60.0
|
||||
# Bands are per axis: the two scores have genuinely different realized ranges,
|
||||
# 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
|
||||
|
||||
HY_OAS_MILD = 3.5
|
||||
HY_OAS_ELEVATED = 5.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 = {
|
||||
"price": 40.0,
|
||||
@@ -61,11 +100,15 @@ STATE_WEIGHTS = {
|
||||
"credit": 20.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 = {
|
||||
"breadth_divergence": 50.0,
|
||||
"breadth_divergence": 45.0,
|
||||
"relative_strength": 30.0,
|
||||
"capex": 12.0,
|
||||
"earnings_reaction": 8.0,
|
||||
"credit_impulse": 25.0,
|
||||
}
|
||||
|
||||
# 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")
|
||||
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}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def band_for(score: float) -> str:
|
||||
if score < 30:
|
||||
def _interpolate(x: float, anchors: tuple[tuple[float, float], ...]) -> float:
|
||||
"""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"
|
||||
if score < 60:
|
||||
if score < elevated:
|
||||
return "watch"
|
||||
if score < 80:
|
||||
if score < breaking:
|
||||
return "elevated"
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
peak = max(closes[-252:])
|
||||
if peak <= 0:
|
||||
return None
|
||||
dd_pct = (peak - closes[-1]) / peak * 100.0
|
||||
return _clamp(dd_pct * 5.0)
|
||||
return (peak - closes[-1]) / peak * 100.0
|
||||
|
||||
|
||||
def p3_drawdown(smh: list[float], qqq: list[float]) -> float | None:
|
||||
vals = [v for v in (_drawdown(smh), _drawdown(qqq)) if v is not None]
|
||||
return max(vals) if vals else None
|
||||
def _drawdown(closes: list[float]) -> float | None:
|
||||
dd_pct = drawdown_pct(closes)
|
||||
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:
|
||||
@@ -220,18 +287,68 @@ def _oas_absolute_score(value: float) -> float:
|
||||
|
||||
|
||||
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:
|
||||
return None
|
||||
latest = oas_values[-1]
|
||||
absolute = _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 = _clamp((percentile - 50.0) / 45.0 * 100.0)
|
||||
return round(absolute * 0.7 + relative * 0.3, 2)
|
||||
return round(_oas_absolute_score(oas_values[-1]), 2)
|
||||
|
||||
|
||||
def w3_credit_impulse(
|
||||
oas_values: list[float], lookback: int = W3_OAS_LOOKBACK
|
||||
) -> float | None:
|
||||
"""HY OAS rate of change: widening only, relative so it works at any level.
|
||||
|
||||
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:
|
||||
@@ -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())
|
||||
available_weight = sum(
|
||||
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
|
||||
return {
|
||||
"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),
|
||||
"minimum_coverage": MIN_COVERAGE,
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
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]
|
||||
|
||||
|
||||
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:
|
||||
candidate = d + timedelta(days=1)
|
||||
while candidate.weekday() >= 5:
|
||||
@@ -340,19 +477,31 @@ def _fundamental_effective_date(overrides: dict) -> date | 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)
|
||||
if effective is None or as_of < effective:
|
||||
return None, None, {"effective_date": effective.isoformat() if effective else None, "age_days": None}
|
||||
age = (as_of - effective).days
|
||||
stale = age > int(config.get("fundamental_staleness_days", 80))
|
||||
f1 = overrides.get("f1_score")
|
||||
f3 = overrides.get("f3_score")
|
||||
return (
|
||||
None if stale or f1 is None else _clamp(float(f1)),
|
||||
None if stale or f3 is None else _clamp(float(f3)),
|
||||
{"effective_date": effective.isoformat(), "age_days": age, "stale": stale},
|
||||
)
|
||||
pending = effective is None or as_of < effective
|
||||
age = None if pending else (as_of - effective).days
|
||||
stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
|
||||
return {
|
||||
"available": not pending and not stale,
|
||||
"pending": pending,
|
||||
"stale": stale,
|
||||
"effective_date": effective.isoformat() if effective else None,
|
||||
"age_days": age,
|
||||
"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:
|
||||
@@ -393,12 +542,14 @@ def _compute_index(
|
||||
vix_item = _item_asof(vix_series, as_of)
|
||||
vix_score = p5_volatility(vix_item[1] if vix_item else None)
|
||||
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)
|
||||
|
||||
divergence = _value_asof(divergence_series, as_of)
|
||||
relative_strength = p4_relative_strength(smh, spy)
|
||||
f1, f3, fundamental_meta = _fundamental_scores_asof(overrides, config, as_of)
|
||||
sensors = warning_sensor_scores(divergence, smh, spy, oas_window)
|
||||
relative_strength = sensors["relative_strength"]
|
||||
credit_impulse = sensors["credit_impulse"]
|
||||
overlay = fundamental_overlay(overrides, config, as_of)
|
||||
|
||||
state_pillars = [
|
||||
{
|
||||
@@ -444,21 +595,22 @@ def _compute_index(
|
||||
"sensors": [_sensor("W2", "60-session relative-strength deterioration", relative_strength)],
|
||||
},
|
||||
{
|
||||
"id": "capex",
|
||||
"label": "Hyperscaler capex revisions",
|
||||
"score": round(f1, 1) if f1 is not None else None,
|
||||
"sensors": [_sensor("F1", "Capex guidance cuts", f1)],
|
||||
},
|
||||
{
|
||||
"id": "earnings_reaction",
|
||||
"label": "Good news, stock down",
|
||||
"score": round(f3, 1) if f3 is not None else None,
|
||||
"sensors": [_sensor("F3", "Abnormal earnings reaction", f3)],
|
||||
"id": "credit_impulse",
|
||||
"label": "Credit impulse",
|
||||
"score": round(credit_impulse, 1) if credit_impulse is not None else None,
|
||||
"sensors": [
|
||||
_sensor(
|
||||
"W3",
|
||||
f"HY OAS {W3_OAS_LOOKBACK}-session widening",
|
||||
credit_impulse,
|
||||
oas=oas_item[1] if oas_item else None,
|
||||
)
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
state = _score_pillars(state_pillars, STATE_WEIGHTS)
|
||||
warning = _score_pillars(warning_pillars, WARNING_WEIGHTS)
|
||||
state = _score_pillars(state_pillars, STATE_WEIGHTS, STATE_BANDS)
|
||||
warning = _score_pillars(warning_pillars, WARNING_WEIGHTS, WARNING_BANDS)
|
||||
|
||||
price_item = _item_asof(prices.get(tickers["leaders"][0]), as_of)
|
||||
dated_sources = {
|
||||
@@ -481,6 +633,7 @@ def _compute_index(
|
||||
"date": as_of.isoformat(),
|
||||
"state": state,
|
||||
"warning": warning,
|
||||
"fundamental_overlay": overlay,
|
||||
"quadrant_config": {
|
||||
"state_divider": QUADRANT_STATE_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_date": breadth_item[0].isoformat() if breadth_item else None,
|
||||
"fundamentals_fetched_at": overrides.get("fetched_at"),
|
||||
"fundamentals_effective_date": fundamental_meta.get("effective_date"),
|
||||
"fundamentals_age_days": fundamental_meta.get("age_days"),
|
||||
"fundamentals_effective_date": overlay.get("effective_date"),
|
||||
"fundamentals_age_days": overlay.get("age_days"),
|
||||
},
|
||||
"data_quality": {
|
||||
"minimum_coverage": MIN_COVERAGE,
|
||||
"oldest_market_input_age_days": max(source_ages.values()) if source_ages else None,
|
||||
"stale_inputs": 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)
|
||||
except (TypeError, ValueError):
|
||||
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
|
||||
capex = _normalise_capex_states(stored.get("capex"), names)
|
||||
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),
|
||||
))
|
||||
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:
|
||||
return False, existing_v2
|
||||
row.total_score = float(state_score or 0.0)
|
||||
@@ -754,7 +915,7 @@ async def _upsert_snapshot(
|
||||
return True, result
|
||||
|
||||
|
||||
def _parse_v2(raw: str) -> dict | None:
|
||||
def _parse_snapshot(raw: str) -> dict | None:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
@@ -762,12 +923,12 @@ def _parse_v2(raw: str) -> dict | 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(
|
||||
select(RegimeSnapshot).order_by(RegimeSnapshot.date.desc()).limit(1000)
|
||||
)
|
||||
for row in result.scalars().all():
|
||||
parsed = _parse_v2(row.breakdown_json)
|
||||
parsed = _parse_snapshot(row.breakdown_json)
|
||||
if parsed is not None:
|
||||
return row, parsed
|
||||
return None
|
||||
@@ -791,8 +952,10 @@ async def update_regime_monitor(db: AsyncSession, rebuild_sessions: int = REBUIL
|
||||
latest_date = leader_series[-1][0]
|
||||
|
||||
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(
|
||||
"BAMLH0A0HYM2", end - timedelta(days=int(365.25 * 13)), end
|
||||
"BAMLH0A0HYM2", end - timedelta(days=HY_OAS_WINDOW_DAYS), end
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
if rebuilding:
|
||||
dates = [d for d, _ in leader_series[-max(1, rebuild_sessions):]]
|
||||
@@ -860,7 +1023,7 @@ async def _result_at_or_before(
|
||||
.limit(1000)
|
||||
)
|
||||
for raw in result.scalars().all():
|
||||
parsed = _parse_v2(raw)
|
||||
parsed = _parse_snapshot(raw)
|
||||
parsed_hash = ((parsed or {}).get("basket") or {}).get("hash")
|
||||
if parsed is not None and (basket_hash is None or parsed_hash == basket_hash):
|
||||
return parsed
|
||||
@@ -877,7 +1040,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
|
||||
|
||||
|
||||
async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||
latest = await _latest_v2_row(db)
|
||||
latest = await _latest_snapshot_row(db)
|
||||
if latest is None:
|
||||
return {"available": False, "reason": "v2 not computed yet"}
|
||||
row, result = latest
|
||||
@@ -902,6 +1065,15 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
|
||||
quality["snapshot_age_days"] = snapshot_age
|
||||
quality["is_fresh"] = bool(quality.get("inputs_fresh")) and snapshot_age <= 4
|
||||
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
|
||||
return result
|
||||
|
||||
@@ -915,7 +1087,7 @@ async def get_regime_history(db: AsyncSession, days: int = 800) -> list[dict]:
|
||||
)
|
||||
out: list[dict] = []
|
||||
for row in result.scalars().all():
|
||||
data = _parse_v2(row.breakdown_json)
|
||||
data = _parse_snapshot(row.breakdown_json)
|
||||
if data is None:
|
||||
continue
|
||||
state, warning = data.get("state") or {}, data.get("warning") or {}
|
||||
|
||||
@@ -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.**
|
||||
@@ -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.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, 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.**
|
||||
@@ -480,6 +480,9 @@ export interface RegimePillar {
|
||||
export interface RegimeReading {
|
||||
score: number | 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;
|
||||
minimum_coverage: number;
|
||||
available_pillars: string[];
|
||||
@@ -487,6 +490,23 @@ export interface RegimeReading {
|
||||
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 {
|
||||
date: string;
|
||||
state: number | null;
|
||||
@@ -503,6 +523,10 @@ export interface RegimeMonitor {
|
||||
date?: string;
|
||||
state?: 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?: {
|
||||
vix: number | null;
|
||||
vix_date: string | null;
|
||||
@@ -534,7 +558,7 @@ export interface RegimeMonitor {
|
||||
}
|
||||
|
||||
export interface RegimeFundamentals {
|
||||
methodology: 'v2';
|
||||
methodology: 'v3';
|
||||
f1_score: number | null;
|
||||
f3_score: number | null;
|
||||
locked: boolean;
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
GoodNewsReaction,
|
||||
RegimeBand,
|
||||
RegimeConfig,
|
||||
RegimeFundamentalOverlay,
|
||||
RegimeFundamentals,
|
||||
RegimeFundamentalsUpdate,
|
||||
RegimeReading,
|
||||
@@ -64,6 +65,8 @@ function ScoreGauge({
|
||||
const complete = reading?.band != null;
|
||||
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
|
||||
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 (
|
||||
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -98,8 +101,15 @@ function ScoreGauge({
|
||||
style={{ left: `${position}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
|
||||
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
|
||||
{/* Thresholds come from the reading: the two axes no longer share them. */}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<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 [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
|
||||
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
|
||||
const cutting = knownCapex.filter((state) => state === 'cutting').length;
|
||||
const derivedF1 = knownCapex.length >= 3 ? Math.round((cutting / knownCapex.length) * 1000) / 10 : null;
|
||||
// Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
|
||||
// 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;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -266,7 +349,7 @@ function FundamentalsEditor({
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
||||
<span>
|
||||
@@ -369,9 +452,10 @@ export default function RegimePage() {
|
||||
label="Warning · deterioration & divergence"
|
||||
reading={data.warning}
|
||||
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>
|
||||
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
||||
<p className="text-xs text-gray-600">
|
||||
Data quality · oldest market input:{' '}
|
||||
{data.data_quality?.oldest_market_input_age_days == null
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_evaluate_alarms_counts_episodes_not_alarm_days():
|
||||
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)
|
||||
closes_by_symbol = {
|
||||
"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)
|
||||
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)]))
|
||||
no_divergence = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
|
||||
assert no_divergence[dates[-1]] == 0
|
||||
confirmed = compute_divergence_series(falling_breadth, falling_benchmark, lookback=3)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,23 +12,30 @@ from sqlalchemy import select
|
||||
|
||||
from app.models.regime_snapshot import RegimeSnapshot
|
||||
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 (
|
||||
DEFAULT_CONFIG,
|
||||
HY_OAS_ELEVATED,
|
||||
HY_OAS_MILD,
|
||||
HY_OAS_STRESSED,
|
||||
STATE_BANDS,
|
||||
WARNING_BANDS,
|
||||
WARNING_WEIGHTS,
|
||||
_compute_index,
|
||||
_fundamental_scores_asof,
|
||||
_score_pillars,
|
||||
band_for,
|
||||
breadth_level_score,
|
||||
drawdown_pct,
|
||||
f2_credit_spreads,
|
||||
fundamental_overlay,
|
||||
p1_trend_break,
|
||||
p2_death_cross,
|
||||
p3_drawdown,
|
||||
p4_relative_strength,
|
||||
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():
|
||||
assert band_for(10) == "stable"
|
||||
assert band_for(30) == "watch"
|
||||
assert band_for(60) == "elevated"
|
||||
assert band_for(80) == "breaking"
|
||||
def test_band_for_is_per_axis():
|
||||
assert band_for(10, STATE_BANDS) == "stable"
|
||||
assert band_for(20, STATE_BANDS) == "watch"
|
||||
assert band_for(50, STATE_BANDS) == "elevated"
|
||||
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():
|
||||
@@ -56,8 +67,97 @@ def test_price_sensors_are_stress_only():
|
||||
assert (p2_death_cross(bearish, bearish) or 0) > 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():
|
||||
@@ -77,12 +177,24 @@ def test_volatility_and_breadth_zero_points():
|
||||
assert breadth_level_score(None) is None
|
||||
|
||||
|
||||
def test_credit_uses_named_anchors_and_constant_series_is_not_extreme():
|
||||
assert f2_credit_spreads([HY_OAS_MILD] * 100) == 0
|
||||
assert f2_credit_spreads([HY_OAS_ELEVATED] * 100) == 35.0
|
||||
assert f2_credit_spreads([HY_OAS_STRESSED] * 100) == 70.0
|
||||
rising = [3.0 + index * 0.01 for index in range(100)]
|
||||
assert (f2_credit_spreads(rising) or 0) > f2_credit_spreads([3.0] * 100)
|
||||
def test_credit_level_is_anchored_and_ignores_the_reference_window():
|
||||
"""The percentile leg is gone: the anchors already encode the long run.
|
||||
|
||||
It ranked the level against whatever history the upstream series happened to
|
||||
serve, and that silently shrank from 10 years to 3 in April 2026 -- three
|
||||
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():
|
||||
@@ -98,31 +210,75 @@ def test_score_pillars_gates_band_below_75_percent_coverage():
|
||||
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 = {
|
||||
"f1_score": 0.0,
|
||||
"f3_score": 100.0,
|
||||
"capex": {"GOOGL": "raising"},
|
||||
"good_news_stock_down": "yes",
|
||||
"fetched_at": "2026-06-01T10:00:00+00:00",
|
||||
"effective_date": "2026-06-02",
|
||||
}
|
||||
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)
|
||||
assert _fundamental_scores_asof(overrides, config, date(2026, 8, 22))[:2] == (None, None)
|
||||
|
||||
pending = fundamental_overlay(overrides, config, date(2026, 6, 1))
|
||||
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"]
|
||||
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(
|
||||
dict.fromkeys(names, "holding"), names
|
||||
) == 0.0
|
||||
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
|
||||
{names[0]: "raising", **dict.fromkeys(names[1:], "holding")}, names
|
||||
) == 37.5
|
||||
assert rms._score_capex_states(
|
||||
{names[0]: "cutting", names[1]: "holding", names[2]: "unknown", names[3]: "unknown"},
|
||||
names,
|
||||
@@ -135,7 +291,7 @@ def test_fundamental_api_rejects_numeric_ordinal_overrides():
|
||||
|
||||
|
||||
@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):
|
||||
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())
|
||||
|
||||
assert result["methodology"] == "v2"
|
||||
assert result["methodology"] == "v3"
|
||||
assert result["f1_score"] is None
|
||||
assert result["f3_score"] is None
|
||||
assert result["good_news_stock_down"] == "mixed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v2_observation_survives_the_methodology_bump(monkeypatch):
|
||||
"""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
|
||||
async def test_unlock_does_not_redate_a_fundamental_observation(monkeypatch):
|
||||
stored = {
|
||||
"methodology": "v2",
|
||||
"methodology": "v3",
|
||||
"f1_score": 100.0,
|
||||
"f3_score": 0.0,
|
||||
"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):
|
||||
names = DEFAULT_CONFIG["tickers"]["hyperscalers"]
|
||||
current = {
|
||||
"methodology": "v2",
|
||||
"methodology": "v3",
|
||||
"f1_score": None,
|
||||
"f3_score": None,
|
||||
"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"
|
||||
)
|
||||
|
||||
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["good_news_stock_down"] == "mixed"
|
||||
assert result["source"] == "manual"
|
||||
@@ -222,10 +409,10 @@ async def test_manual_fundamentals_are_categorical_and_derived(monkeypatch):
|
||||
|
||||
|
||||
@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)
|
||||
first = {
|
||||
"methodology": "v2",
|
||||
"methodology": "v3",
|
||||
"date": snapshot_date.isoformat(),
|
||||
"state": {"score": 10.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 {}, {}
|
||||
|
||||
async def fake_latest(_db):
|
||||
return object(), {"methodology": "v2"}
|
||||
return object(), {"methodology": "v3"}
|
||||
|
||||
async def fake_upsert(_db, result, *, 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_fred_series", fake_fred)
|
||||
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)
|
||||
|
||||
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")
|
||||
sensor_scores = [sensor["score"] for sensor in price["sensors"] if sensor["score"] is not None]
|
||||
assert price["score"] == max(sensor_scores)
|
||||
assert result["methodology"] == "v2"
|
||||
assert result["methodology"] == "v3"
|
||||
assert "combined" not in result
|
||||
assert result["basket"]["members_available"] == 25
|
||||
|
||||
@@ -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 (
|
||||
QUAD_X_DIV,
|
||||
QUAD_Y_DIV,
|
||||
_classify_quadrant,
|
||||
_parse_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(70, 90, None) == "2"
|
||||
assert _classify_quadrant(20, 30, None) == "3"
|
||||
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():
|
||||
assert _classify_quadrant(20, 62, prev="3") == "3"
|
||||
assert _classify_quadrant(20, 66, prev="3") == "1"
|
||||
assert _classify_quadrant(20, 58, prev="1") == "1"
|
||||
assert _classify_quadrant(20, 54, prev="1") == "3"
|
||||
assert _classify_quadrant(20, 42, prev="3") == "3"
|
||||
assert _classify_quadrant(20, 46, prev="3") == "1"
|
||||
assert _classify_quadrant(20, 38, prev="1") == "1"
|
||||
assert _classify_quadrant(20, 34, prev="1") == "3"
|
||||
|
||||
|
||||
def test_state_axis_hysteresis():
|
||||
assert _classify_quadrant(63, 30, prev="3") == "3"
|
||||
assert _classify_quadrant(66, 30, prev="3") == "4"
|
||||
assert _classify_quadrant(57, 30, prev="4") == "4"
|
||||
assert _classify_quadrant(54, 30, prev="4") == "3"
|
||||
assert _classify_quadrant(53, 30, prev="3") == "3"
|
||||
assert _classify_quadrant(56, 30, prev="3") == "4"
|
||||
assert _classify_quadrant(47, 30, prev="4") == "4"
|
||||
assert _classify_quadrant(44, 30, prev="4") == "3"
|
||||
|
||||
|
||||
def test_boundary_sitting_does_not_flip():
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user