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:
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user