The Warning study measured a fitted percentile crossing that nothing consumes. What reaches Telegram is a quadrant change: fixed 50/40 dividers, hysteresis, two-session confirmation, 3-day cooldown. Those thresholds are constants, not fits, so there is no training set to protect and all 11 detected corrections are evaluable instead of the 4 that fell in a holdout. Replaying it: 1/10 corrections, 0.9 false alarms/year. Random alarms at the same firing rate match or beat that in 65% of draws. The panel now carries ablations (does the quadrant machinery earn its place?), external baselines (does the score earn its complexity?), and that null, because a bare "2 of 4" was unreadable in either direction. Nothing in the alert path was retuned on the strength of it. Fundamentals become a third channel rather than a term in either score. v3 cut them arguing 12+8 of 100 points "could not change any published conclusion" -- true only when every technical sensor reads zero; weighted they moved the bar for the 40 divider from 40 to 25. But no fusion weight is measurable either: with ~10 events and no fundamental history, any weight is a policy preference presented as a measurement. So the read is a categorical state (supportive/neutral/adverse/ unknown) with an evidence grade, derived by fixed rules from stored facts, read by confluence. The LLM extracts and explains; it does not score. Absence stays absence throughout. `unknown` is unreachable by averaging, a stale or empty observation may display but never confirm, extraction failures map to `unknown` rather than `mixed`, and the study rows are coverage-matched and marked not-measurable until enough corrections are covered -- otherwise a fortnight of observations renders as 0/10 and reads as a failed test. Observations become a real time series (migration 033); they lived in a single overwritten settings slot, so no history existed to replay. Pre-rename snapshots are adapted rather than discarded. METHODOLOGY stays v4 -- no score changed -- so no reseed; STUDY_SCHEMA moves to 3 and discards the cached report. Post-deploy: re-run Event Study from Admin -> Jobs. The panel reads "not run yet" until then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1055 lines
43 KiB
Python
1055 lines
43 KiB
Python
"""Chronological validation for the AI/Tech Risk Monitor warning score.
|
|
|
|
The outcome is a 10% correction in the leader, never a regime break. Two rules
|
|
are measured against it, and they answer different questions:
|
|
|
|
* **shipped** -- the quadrant-change rule that actually reaches Telegram
|
|
(``alert_service._collect_regime_quadrant``). Its thresholds are fixed
|
|
constants chosen by scenario arithmetic, so nothing is fitted, so there is no
|
|
training set to protect and the whole sample is evaluable. This is the
|
|
headline.
|
|
* **fitted** -- the original study: an 80th-percentile Warning threshold frozen
|
|
on the first 70% of sessions and measured on the last 30%. Kept because it is
|
|
what the methodology document reports, and because a fitted threshold is a
|
|
genuinely different question -- but it is measured on the four corrections that
|
|
happen to fall in the holdout, which is too few to read as a property of the
|
|
score.
|
|
|
|
Both are scored by the same ``evaluate_alarms`` harness, alongside ablations
|
|
(does the quadrant machinery earn its place?), external baselines (does the
|
|
score earn its complexity?), and a random-alarm null (is any of this better than
|
|
chance?). Without those rows a bare "2 of 4" is unreadable in either direction.
|
|
|
|
The fundamental channel is compared, never fused. It appears as its own rule
|
|
(transitions into an adverse state), as a confluence gate (a market crossing kept
|
|
only when the state agrees), and as a market-only comparator over the identical
|
|
window -- because with ~10 correction events and almost no fundamental history,
|
|
any weight that combined it with the market axes would be a policy preference
|
|
presented as a measurement.
|
|
|
|
Those three rows are **coverage-matched**: scored only on the sessions where the
|
|
channel had usable context and on the corrections whose warning horizon fell
|
|
inside it, and marked ``measurable: false`` until enough corrections are covered.
|
|
A fundamental rule scores zero whether it is wrong or merely absent, so scoring
|
|
it over the market rows' full sample would turn a fortnight of observations into
|
|
a 0/10 that reads as a failed test.
|
|
|
|
Still labelled exploratory while the fixed breadth basket is reconstructed
|
|
before its freeze date.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import random
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.services import breadth_service, settings_store
|
|
from app.services import regime_monitor_service as rms
|
|
from app.services.admin_service import update_setting
|
|
from app.services.alert_service import (
|
|
QUAD_COOLDOWN_DAYS,
|
|
QUAD_MARGIN,
|
|
QUAD_X_DIV,
|
|
QUAD_Y_DIV,
|
|
_classify_quadrant,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
KEY_REPORT = "regime_event_study"
|
|
|
|
# Report shape, independent of METHODOLOGY. A cached report from an older shape
|
|
# parses fine and reports the current methodology, so without this check the
|
|
# panel would render a report missing half its blocks. Bumping discards the cache
|
|
# the way a methodology change does -- and it is the *only* thing that does so
|
|
# here, because the fundamental-channel rework left METHODOLOGY on v4 (the scores
|
|
# did not change), so the methodology check cannot catch a stale report.
|
|
STUDY_SCHEMA = 3
|
|
|
|
EVENT_THRESHOLD_PCT = 10.0
|
|
EVENT_COOLDOWN_DAYS = 40
|
|
DRAWDOWN_LOOKBACK = 252
|
|
HORIZON_DAYS = 20
|
|
WARN_PERCENTILE = 80.0
|
|
TRAIN_FRACTION = 0.70
|
|
# Below this many holdout corrections, recall is one event away from a very
|
|
# different headline and should not be read as a property of the score.
|
|
MIN_EVENTS_FOR_CONFIDENCE = 8
|
|
SENSOR_MISMATCH_TOLERANCE = 0.10
|
|
|
|
# _collect_regime_quadrant confirms against get_regime_history(db, days=14), so a
|
|
# prior session older than that window is not available to confirm with.
|
|
QUAD_HISTORY_DAYS = 14
|
|
# Quadrants with Warning above its divider: "1" early warning, "2" active stress.
|
|
WARNING_QUADRANTS = ("1", "2")
|
|
STRESS_QUADRANT = ("2",)
|
|
|
|
# Draws for the random-alarm null. Seeded, because a cached report that moves
|
|
# on re-run for RNG reasons is worse than no report.
|
|
NULL_DRAWS = 2000
|
|
NULL_SEED = 20260812
|
|
|
|
BASELINE_SMA_WINDOW = 50
|
|
BASELINE_VIX_LEVEL = 20.0
|
|
|
|
|
|
def _median(values: list[float]) -> float | None:
|
|
if not values:
|
|
return None
|
|
ordered = sorted(values)
|
|
middle = len(ordered) // 2
|
|
return (
|
|
float(ordered[middle])
|
|
if len(ordered) % 2
|
|
else (ordered[middle - 1] + ordered[middle]) / 2.0
|
|
)
|
|
|
|
|
|
def _percentile(values: list[float], pct: float) -> float | None:
|
|
ordered = sorted(v for v in values if v is not None)
|
|
if not ordered:
|
|
return None
|
|
position = (len(ordered) - 1) * pct / 100.0
|
|
lower = int(position)
|
|
upper = min(lower + 1, len(ordered) - 1)
|
|
return ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower)
|
|
|
|
|
|
def detect_events(
|
|
closes: list[float],
|
|
dates: list[date],
|
|
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
|
lookback: int = DRAWDOWN_LOOKBACK,
|
|
cooldown: int = EVENT_COOLDOWN_DAYS,
|
|
) -> list[dict]:
|
|
"""Rising-edge corrections from the trailing 52-week high."""
|
|
events: list[dict] = []
|
|
previous_drawdown = 0.0
|
|
last_event = -10**9
|
|
for index, close in enumerate(closes):
|
|
high = max(closes[max(0, index - lookback + 1): index + 1])
|
|
drawdown = (high - close) / high * 100.0 if high > 0 else 0.0
|
|
if (
|
|
drawdown >= threshold_pct
|
|
and previous_drawdown < threshold_pct
|
|
and index - last_event >= cooldown
|
|
):
|
|
events.append({
|
|
"date": dates[index].isoformat(),
|
|
"index": index,
|
|
"depth_pct": round(drawdown, 1),
|
|
})
|
|
last_event = index
|
|
previous_drawdown = drawdown
|
|
return events
|
|
|
|
|
|
def alarm_episodes(
|
|
indicator: dict[date, float],
|
|
dates: list[date],
|
|
threshold: float,
|
|
start_index: int = 1,
|
|
) -> list[int]:
|
|
"""Indices where the warning crosses upward; it must reset below first."""
|
|
alarms: list[int] = []
|
|
was_high = False
|
|
if start_index > 0:
|
|
previous = indicator.get(dates[start_index - 1])
|
|
was_high = previous is not None and previous >= threshold
|
|
for index in range(start_index, len(dates)):
|
|
value = indicator.get(dates[index])
|
|
if value is None:
|
|
continue
|
|
high = value >= threshold
|
|
if high and not was_high:
|
|
alarms.append(index)
|
|
was_high = high
|
|
return alarms
|
|
|
|
|
|
def evaluate_alarms(
|
|
alarm_indices: list[int],
|
|
event_indices: list[int],
|
|
dates: list[date],
|
|
horizon: int = HORIZON_DAYS,
|
|
) -> dict:
|
|
"""Event recall, episode false alarms, and lead time for one holdout."""
|
|
leads: list[float] = []
|
|
per_event: list[dict] = []
|
|
warned = 0
|
|
for event_index in event_indices:
|
|
matching = [
|
|
alarm for alarm in alarm_indices if 0 < event_index - alarm <= horizon
|
|
]
|
|
lead = max((event_index - alarm for alarm in matching), default=None)
|
|
if lead is not None:
|
|
warned += 1
|
|
leads.append(float(lead))
|
|
per_event.append({
|
|
"date": dates[event_index].isoformat(),
|
|
"warned": lead is not None,
|
|
"lead_days": lead,
|
|
})
|
|
|
|
false_alarms = sum(
|
|
1
|
|
for alarm in alarm_indices
|
|
if not any(0 < event - alarm <= horizon for event in event_indices)
|
|
)
|
|
return {
|
|
"events": len(event_indices),
|
|
"events_warned": warned,
|
|
"events_missed": len(event_indices) - warned,
|
|
"alarm_episodes": len(alarm_indices),
|
|
"false_alarms": false_alarms,
|
|
"median_lead_days": _median(leads),
|
|
"per_event": per_event,
|
|
}
|
|
|
|
|
|
def _score_rule(
|
|
alarm_indices: list[int],
|
|
event_indices: list[int],
|
|
dates: list[date],
|
|
horizon: int,
|
|
sessions: int,
|
|
) -> dict:
|
|
"""``evaluate_alarms`` plus the annualised false-alarm rate for one rule.
|
|
|
|
The rate is ``None`` when the rule had no eligible sessions. Dividing by a
|
|
tiny floor instead produced 5e9 alarms/year for a coverage-matched rule with
|
|
an empty window -- a number that means "undefined" while looking like a
|
|
measurement, which is the failure mode this whole panel is built to avoid.
|
|
"""
|
|
metrics = evaluate_alarms(alarm_indices, event_indices, dates, horizon)
|
|
metrics["false_alarms_per_year"] = (
|
|
round(metrics["false_alarms"] / (sessions / 252.0), 2) if sessions > 0 else None
|
|
)
|
|
return metrics
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The shipped rule
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _axis_rows(
|
|
prices: dict[str, rms.Series],
|
|
vix_series: rms.Series | None,
|
|
oas_series: rms.Series | None,
|
|
breadth_series: rms.Series | None,
|
|
divergence_series: rms.Series | None,
|
|
dates: list[date],
|
|
config: dict,
|
|
observations: list[dict] | None = None,
|
|
) -> dict[date, dict]:
|
|
"""State and Warning per session, from the function that writes snapshots.
|
|
|
|
Calling ``_compute_index`` rather than re-deriving the two axes is the same
|
|
anti-drift argument that produced ``warning_sensor_scores``: the v2 study
|
|
re-derived Warning by hand and would have kept measuring the old construct
|
|
through a scoring change. State has no such shared helper, so the whole
|
|
snapshot builder is the shared definition.
|
|
|
|
``observations`` is the point-in-time fundamental series. It does not enter
|
|
either score -- the fundamental channel is categorical and read by confluence
|
|
-- but the per-session ``fundamental_state`` it produces is what the
|
|
confluence rule below is measured on, so it has to be the same series
|
|
production reports from. Every variant in this module reads its Warning from
|
|
these rows, so there is no second derivation to fall out of step.
|
|
"""
|
|
rows: dict[date, dict] = {}
|
|
for session in dates:
|
|
snapshot = rms._compute_index(
|
|
prices,
|
|
vix_series,
|
|
oas_series,
|
|
{},
|
|
config,
|
|
session,
|
|
breadth_series=breadth_series,
|
|
divergence_series=divergence_series,
|
|
observations=observations or [],
|
|
)
|
|
state = snapshot["state"]
|
|
warning = snapshot["warning"]
|
|
rows[session] = {
|
|
"state": state.get("score"),
|
|
"warning": warning.get("score"),
|
|
"fundamental_state": (snapshot.get("fundamental_context") or {}).get("state"),
|
|
# `usable`, not `available`: a stale observation keeps its state for
|
|
# display but stops counting as evidence, and an observation whose
|
|
# extraction failed on everything is fresh but knows nothing. Either
|
|
# one counted here would inflate the covered window with sessions the
|
|
# channel could not have contributed to.
|
|
"fundamental_usable": bool(
|
|
(snapshot.get("fundamental_context") or {}).get("usable")
|
|
),
|
|
"state_coverage": state.get("coverage") or 0.0,
|
|
"warning_coverage": warning.get("coverage") or 0.0,
|
|
# The score renormalises over available sensors, so a session backed
|
|
# by two is not drawn from the same distribution as one backed by
|
|
# three, and a frozen threshold assumes it is.
|
|
"warning_sensors": len(warning.get("available_pillars") or []),
|
|
"inputs_fresh": bool((snapshot.get("data_quality") or {}).get("inputs_fresh")),
|
|
}
|
|
return rows
|
|
|
|
|
|
def _publishable(row: dict | None) -> bool:
|
|
"""What ``get_regime_history`` leaves for the alert to confirm against.
|
|
|
|
Deliberately not freshness-gated: ``_collect_regime_quadrant`` checks
|
|
``is_fresh`` on today's live reading only, while the prior session comes from
|
|
stored history where the only filter is a published band on both axes.
|
|
"""
|
|
return (
|
|
row is not None
|
|
and row["state"] is not None
|
|
and row["warning"] is not None
|
|
and row["state_coverage"] >= rms.MIN_COVERAGE
|
|
and row["warning_coverage"] >= rms.MIN_COVERAGE
|
|
)
|
|
|
|
|
|
def _prior_publishable(
|
|
rows: dict[date, dict], dates: list[date], index: int, history_days: int
|
|
) -> dict | None:
|
|
"""``valid[-2]``: the previous published session inside the 14-day window.
|
|
|
|
The monitor writes today's snapshot before the alert step runs
|
|
(``job_catalog._DAILY_PIPELINE_STEPS``), so ``valid[-1]`` is today and this
|
|
is genuinely the prior session rather than t-2.
|
|
"""
|
|
cutoff = dates[index] - timedelta(days=history_days)
|
|
for position in range(index - 1, -1, -1):
|
|
if dates[position] < cutoff:
|
|
return None
|
|
candidate = rows.get(dates[position])
|
|
if _publishable(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def replay_quadrant_changes(
|
|
rows: dict[date, dict],
|
|
dates: list[date],
|
|
state_divider: float = QUAD_X_DIV,
|
|
warning_divider: float = QUAD_Y_DIV,
|
|
margin: float = QUAD_MARGIN,
|
|
cooldown_days: int = QUAD_COOLDOWN_DAYS,
|
|
history_days: int = QUAD_HISTORY_DAYS,
|
|
) -> list[dict]:
|
|
"""Every quadrant change the shipped alert would have sent, in order.
|
|
|
|
A faithful replay of ``_collect_regime_quadrant``, including three details a
|
|
state machine written from first principles gets wrong:
|
|
|
|
* the prior session is classified against the *current baseline*, not against
|
|
its own predecessor, so confirmation asks "did yesterday already look like
|
|
this change" rather than "did yesterday change too";
|
|
* the baseline advances only when an alert actually fires, so a change that
|
|
fails confirmation or cooldown is re-evaluated against the old quadrant on
|
|
the next session rather than being forgotten;
|
|
* one cooldown is shared by every quadrant change, so a 3->4 alert can
|
|
swallow a 4->2 alert three days later.
|
|
|
|
Returns the fires themselves rather than alarm indices, because which
|
|
transitions count as a *warning* is the caller's question: entering
|
|
Warning-high territory and entering both-high territory are different rules
|
|
over the same replay.
|
|
"""
|
|
fires: list[dict] = []
|
|
baseline: str | None = None
|
|
baseline_date: date | None = None
|
|
|
|
for index, session in enumerate(dates):
|
|
row = rows.get(session)
|
|
if not _publishable(row) or not row["inputs_fresh"]:
|
|
continue
|
|
x, y = float(row["state"]), float(row["warning"])
|
|
|
|
if baseline is None: # seeds silently, exactly as a fresh install does
|
|
baseline = _classify_quadrant(x, y, None, margin, state_divider, warning_divider)
|
|
baseline_date = session
|
|
continue
|
|
|
|
new_quadrant = _classify_quadrant(x, y, baseline, margin, state_divider, warning_divider)
|
|
if new_quadrant == baseline:
|
|
continue
|
|
|
|
prior = _prior_publishable(rows, dates, index, history_days)
|
|
if prior is None:
|
|
continue
|
|
prior_quadrant = _classify_quadrant(
|
|
float(prior["state"]), float(prior["warning"]),
|
|
baseline, margin, state_divider, warning_divider,
|
|
)
|
|
if prior_quadrant != new_quadrant:
|
|
continue
|
|
|
|
if baseline_date is not None and (session - baseline_date).days < cooldown_days:
|
|
continue
|
|
|
|
fires.append({
|
|
"index": index,
|
|
"date": session.isoformat(),
|
|
"from": baseline,
|
|
"to": new_quadrant,
|
|
"state": x,
|
|
"warning": y,
|
|
})
|
|
baseline, baseline_date = new_quadrant, session
|
|
|
|
return fires
|
|
|
|
|
|
def entry_alarms(fires: list[dict], entry: tuple[str, ...]) -> list[int]:
|
|
"""Fires that *enter* the given quadrant set from outside it."""
|
|
return [f["index"] for f in fires if f["to"] in entry and f["from"] not in entry]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ablations, baselines, null
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _usable_adverse(rows: dict[date, dict], session: date) -> bool:
|
|
"""Adverse *and* still within its staleness horizon.
|
|
|
|
Both callers need this pair, and neither may use the state alone: the state
|
|
survives going stale so the card can show it, which would otherwise let a
|
|
months-old read confirm crossings indefinitely.
|
|
"""
|
|
row = rows.get(session) or {}
|
|
return row.get("fundamental_state") == "adverse" and bool(row.get("fundamental_usable"))
|
|
|
|
|
|
def adverse_episodes(
|
|
rows: dict[date, dict], dates: list[date], start_index: int
|
|
) -> list[int]:
|
|
"""Sessions where the fundamental state *becomes* usably adverse.
|
|
|
|
The market rules alarm on a rising-edge crossing; a categorical state has no
|
|
crossing, so its analogue is the transition into ``adverse``. That keeps the
|
|
row comparable with every other row in the table rather than counting every
|
|
day the state happens to sit there.
|
|
"""
|
|
alarms: list[int] = []
|
|
was_adverse = start_index > 0 and _usable_adverse(rows, dates[start_index - 1])
|
|
for index in range(start_index, len(dates)):
|
|
if dates[index] not in rows:
|
|
continue
|
|
adverse = _usable_adverse(rows, dates[index])
|
|
if adverse and not was_adverse:
|
|
alarms.append(index)
|
|
was_adverse = adverse
|
|
return alarms
|
|
|
|
|
|
def confluence_episodes(
|
|
warning_alarms: list[int], rows: dict[date, dict], dates: list[date]
|
|
) -> list[int]:
|
|
"""Warning crossings that happen while the fundamental state is usably adverse.
|
|
|
|
Deliberately gated on the market crossing rather than on either channel
|
|
moving: it preserves the rising-edge semantics every other row uses, so the
|
|
column measures "does requiring fundamental agreement help?" instead of a
|
|
differently-shaped rule that cannot be compared with the others.
|
|
"""
|
|
return [index for index in warning_alarms if _usable_adverse(rows, dates[index])]
|
|
|
|
|
|
def covered_events(
|
|
event_indices: list[int],
|
|
rows: dict[date, dict],
|
|
dates: list[date],
|
|
horizon: int,
|
|
) -> list[int]:
|
|
"""Corrections a fundamental rule actually had a chance to warn about.
|
|
|
|
An alarm counts only if it fires in ``[event - horizon, event - 1]``, so a
|
|
correction is *coverable* only if the channel had usable context somewhere in
|
|
that window. Scoring these rules against every correction instead would make
|
|
one day of observation render as 0/10 -- an untested rule reported as a
|
|
failed one, which is the exact mistake the ``measurable`` flag exists to
|
|
prevent for the empty-table case.
|
|
"""
|
|
covered: list[int] = []
|
|
for event_index in event_indices:
|
|
window = range(max(0, event_index - horizon), event_index)
|
|
if any(
|
|
bool((rows.get(dates[index]) or {}).get("fundamental_usable"))
|
|
for index in window
|
|
):
|
|
covered.append(event_index)
|
|
return covered
|
|
|
|
|
|
def eligible_sessions(
|
|
rows: dict[date, dict], dates: list[date], start_index: int
|
|
) -> int:
|
|
"""Sessions a fundamental rule could have fired on, for the FA/year rate.
|
|
|
|
Annualising over the whole window instead would divide a rule's false alarms
|
|
by years in which it was structurally incapable of firing, reporting a
|
|
flattering rate that means nothing.
|
|
"""
|
|
return sum(
|
|
1
|
|
for session in dates[start_index:]
|
|
if bool((rows.get(session) or {}).get("fundamental_usable"))
|
|
)
|
|
|
|
|
|
def below_average_series(
|
|
series: rms.Series, window: int = BASELINE_SMA_WINDOW
|
|
) -> dict[date, float]:
|
|
"""100 while the close sits under its ``window``-session average, else 0."""
|
|
out: dict[date, float] = {}
|
|
closes = [value for _, value in series]
|
|
for index, (session, close) in enumerate(series):
|
|
if index + 1 < window:
|
|
continue
|
|
average = sum(closes[index + 1 - window: index + 1]) / window
|
|
out[session] = 100.0 if close < average else 0.0
|
|
return out
|
|
|
|
|
|
def _null_model(
|
|
alarm_count: int,
|
|
event_indices: list[int],
|
|
dates: list[date],
|
|
horizon: int,
|
|
start_index: int,
|
|
observed_warned: int,
|
|
draws: int = NULL_DRAWS,
|
|
seed: int = NULL_SEED,
|
|
) -> dict | None:
|
|
"""Recall from alarms scattered at random over the same evaluable sessions.
|
|
|
|
Drawn only from sessions a real rule could have fired on: over the whole
|
|
sample the null would be diluted by warm-up sessions and would understate
|
|
what chance achieves. That matters here -- with ~11 events and a 20-session
|
|
horizon, a sixth of the sample already sits inside a hit window.
|
|
|
|
Corrections cluster, and uniform placement does not, so this is the floor
|
|
rather than the bar: an alarm process that clusters would beat it for
|
|
reasons that have nothing to do with foresight.
|
|
"""
|
|
population = range(start_index, len(dates))
|
|
if alarm_count <= 0 or not event_indices or alarm_count > len(population):
|
|
return None
|
|
rng = random.Random(seed)
|
|
recalls: list[int] = []
|
|
for _ in range(draws):
|
|
picks = sorted(rng.sample(population, alarm_count))
|
|
recalls.append(evaluate_alarms(picks, event_indices, dates, horizon)["events_warned"])
|
|
mean = sum(recalls) / len(recalls)
|
|
variance = sum((value - mean) ** 2 for value in recalls) / len(recalls)
|
|
return {
|
|
"draws": draws,
|
|
"alarms_per_draw": alarm_count,
|
|
"events": len(event_indices),
|
|
"mean_warned": round(mean, 2),
|
|
"sd_warned": round(variance ** 0.5, 2),
|
|
"observed_warned": observed_warned,
|
|
"p_at_least_observed": round(
|
|
sum(1 for value in recalls if value >= observed_warned) / len(recalls), 3
|
|
),
|
|
}
|
|
|
|
|
|
def _reliability(
|
|
dates: list[date],
|
|
split: int,
|
|
backing: dict[date, int],
|
|
events_detected: int,
|
|
events_in_holdout: int,
|
|
) -> dict:
|
|
"""How far the *fitted* variant's headline metrics can be trusted.
|
|
|
|
Two things repeatedly invite over-reading it:
|
|
|
|
* The holdout carries only the corrections that fall in the last 30% of the
|
|
sample. A "2/4" is one event away from "3/4", and in practice the events
|
|
that flip are decided by where the frozen threshold happens to land rather
|
|
than by whether the score saw anything.
|
|
* The score renormalises over available sensors, so a training window that
|
|
predates a sensor's history freezes a threshold on a different construct
|
|
than the holdout is measured against.
|
|
|
|
Neither applies to the shipped rule, whose thresholds are fixed constants --
|
|
but the second one does not vanish, it relocates: see ``_era_split``.
|
|
"""
|
|
expected = len(rms.WARNING_WEIGHTS)
|
|
train = [backing[d] for d in dates[:split] if d in backing]
|
|
holdout = [backing[d] for d in dates[split:] if d in backing]
|
|
train_full = sum(1 for n in train if n == expected) / len(train) if train else 0.0
|
|
holdout_full = sum(1 for n in holdout if n == expected) / len(holdout) if holdout else 0.0
|
|
return {
|
|
"events_detected": events_detected,
|
|
"events_in_holdout": events_in_holdout,
|
|
"minimum_events": MIN_EVENTS_FOR_CONFIDENCE,
|
|
"underpowered": events_in_holdout < MIN_EVENTS_FOR_CONFIDENCE,
|
|
"sensors_expected": expected,
|
|
"train_full_sensor_share": round(train_full * 100, 1),
|
|
"holdout_full_sensor_share": round(holdout_full * 100, 1),
|
|
"sensor_coverage_mismatch": abs(train_full - holdout_full) > SENSOR_MISMATCH_TOLERANCE,
|
|
}
|
|
|
|
|
|
def _era_split(
|
|
alarms: list[int],
|
|
event_indices: list[int],
|
|
dates: list[date],
|
|
horizon: int,
|
|
start_index: int,
|
|
credit_from: date | None,
|
|
) -> dict | None:
|
|
"""Shipped-rule metrics either side of the credit sensor's first session.
|
|
|
|
Dropping the fitted threshold makes the whole sample evaluable, which is the
|
|
point -- but most of the extra events sit before 2023-08, where W3 does not
|
|
exist and Warning renormalises to ``(W1*45 + W2*30)/75``. The fixed 40
|
|
divider is then applied to a different construct than it was reasoned about,
|
|
so the coverage caveat does not disappear with the split; it relocates from
|
|
the threshold to the score. Reporting the two eras separately is what keeps
|
|
the fuller sample from being a differently misleading headline.
|
|
|
|
The pre-credit era is close to a "Warning without W3" ablation on real
|
|
sessions -- and a clean one, because the fundamental channel is not a term in
|
|
Warning at all, so the two eras differ by W3 and nothing else. That stays
|
|
true however much fundamental history accumulates.
|
|
|
|
Alarms and events are assigned to eras by index, so an alarm days before the
|
|
boundary that matched an event days after it lands in the earlier era. With
|
|
the eras years long and the events sparse, that costs nothing.
|
|
"""
|
|
if credit_from is None:
|
|
return None
|
|
boundary = next(
|
|
(index for index, session in enumerate(dates) if session >= credit_from), None
|
|
)
|
|
if boundary is None or boundary <= start_index or boundary >= len(dates):
|
|
return None
|
|
|
|
def slice_metrics(low: int, high: int) -> dict:
|
|
sessions = max(0, high - low)
|
|
metrics = _score_rule(
|
|
[a for a in alarms if low <= a < high],
|
|
[e for e in event_indices if low <= e < high],
|
|
dates, horizon, sessions,
|
|
)
|
|
metrics.pop("per_event", None)
|
|
metrics["sessions"] = sessions
|
|
return metrics
|
|
|
|
return {
|
|
"credit_from": credit_from.isoformat(),
|
|
"pre_credit": {
|
|
"label": "W1+W2 only",
|
|
"start": dates[start_index].isoformat(),
|
|
"end": dates[boundary - 1].isoformat(),
|
|
**slice_metrics(start_index, boundary),
|
|
},
|
|
"full_coverage": {
|
|
"label": "all three sensors",
|
|
"start": dates[boundary].isoformat(),
|
|
"end": dates[-1].isoformat(),
|
|
**slice_metrics(boundary, len(dates)),
|
|
},
|
|
}
|
|
|
|
|
|
def _warning_from_rows(
|
|
rows: dict[date, dict], dates: list[date]
|
|
) -> tuple[dict[date, float], dict[date, int]]:
|
|
"""Published Warning per session plus how many sensors backed it.
|
|
|
|
Read off ``_axis_rows`` rather than recomputed. v2 re-derived Warning by hand
|
|
from ``WARNING_WEIGHTS`` and would have kept measuring the old construct
|
|
after a scoring change; a second derivation here would have done the same to
|
|
any later change to how Warning is assembled -- silently, in the fitted
|
|
variant and the ``warning_bare`` ablation, while the shipped replay moved on
|
|
without it.
|
|
"""
|
|
out: dict[date, float] = {}
|
|
backing: dict[date, int] = {}
|
|
for session in dates:
|
|
row = rows.get(session)
|
|
if row is None or row["warning"] is None:
|
|
continue
|
|
out[session] = float(row["warning"])
|
|
backing[session] = int(row["warning_sensors"])
|
|
return out, backing
|
|
|
|
|
|
def _rule_row(
|
|
rule_id: str,
|
|
label: str,
|
|
kind: str,
|
|
note: str,
|
|
alarms: list[int],
|
|
event_indices: list[int],
|
|
dates: list[date],
|
|
horizon: int,
|
|
sessions: int,
|
|
measurable: bool = True,
|
|
) -> dict:
|
|
"""One comparison row.
|
|
|
|
``measurable=False`` marks a rule whose *input* is too thin to have been
|
|
tested, not one that failed. A fundamental rule scores 0/N whether it is
|
|
wrong or merely absent, and a 0/N sitting in this table would read as
|
|
tested-and-failed -- the same false precision the whole restructure exists to
|
|
remove. It stays false until the channel has covered
|
|
``MIN_EVENTS_FOR_CONFIDENCE`` corrections, because a 1/1 or 0/2 over a
|
|
two-week exposure is not a result either.
|
|
"""
|
|
metrics = _score_rule(alarms, event_indices, dates, horizon, sessions)
|
|
metrics.pop("per_event", None)
|
|
return {
|
|
"id": rule_id,
|
|
"label": label,
|
|
"kind": kind,
|
|
"note": note,
|
|
"measurable": measurable,
|
|
**metrics,
|
|
}
|
|
|
|
|
|
async def run_event_study(
|
|
db: AsyncSession,
|
|
threshold_pct: float = EVENT_THRESHOLD_PCT,
|
|
horizon: int = HORIZON_DAYS,
|
|
) -> dict:
|
|
config = await rms.get_regime_config(db)
|
|
end = date.today()
|
|
start = end - timedelta(days=5 * 365 + 30)
|
|
prices = await rms._fetch_prices(config, start, end)
|
|
leader = config["tickers"]["leaders"][0]
|
|
benchmark = sorted(prices.get(leader, []), key=lambda item: item[0])
|
|
if len(benchmark) < 500:
|
|
return {"available": False, "reason": "insufficient benchmark history"}
|
|
|
|
dates = [d for d, _ in benchmark]
|
|
closes = [value for _, value in benchmark]
|
|
breadth, _ = await breadth_service.compute_breadth_details(
|
|
db, config["breadth_basket"], window=200, min_tickers=20
|
|
)
|
|
divergence = breadth_service.compute_divergence_series(breadth, benchmark)
|
|
oas_series = await rms._fetch_fred_series("BAMLH0A0HYM2", start, end)
|
|
# State needs volatility, which the Warning-only study never fetched.
|
|
vix_series = await rms._fetch_fred_series("VIXCLS", start, end)
|
|
# The point-in-time fundamental series. It is not in either score; it drives
|
|
# the categorical channel the confluence rule below is measured on.
|
|
observations = await rms.get_fundamental_observations(db)
|
|
# 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] if oas_series else None
|
|
|
|
all_events = detect_events(closes, dates, threshold_pct)
|
|
all_event_indices = [event["index"] for event in all_events]
|
|
|
|
# --- one pass; every rule below reads its Warning from these rows ----
|
|
rows = _axis_rows(
|
|
prices,
|
|
vix_series,
|
|
oas_series,
|
|
rms._mapping_series(breadth),
|
|
rms._mapping_series(divergence),
|
|
dates,
|
|
config,
|
|
observations,
|
|
)
|
|
warning, backing = _warning_from_rows(rows, dates)
|
|
fires = replay_quadrant_changes(rows, dates)
|
|
# Nothing can alarm before the baseline seeds, so every rule is measured from
|
|
# the same session and the comparison stays like-for-like.
|
|
seeded = next(
|
|
(
|
|
index
|
|
for index, session in enumerate(dates)
|
|
if _publishable(rows.get(session)) and rows[session]["inputs_fresh"]
|
|
),
|
|
None,
|
|
)
|
|
if seeded is None:
|
|
return {"available": False, "reason": "no session with publishable coverage"}
|
|
evaluable_start = seeded + 1
|
|
evaluable_sessions = max(1, len(dates) - evaluable_start)
|
|
evaluable_events = [index for index in all_event_indices if index >= evaluable_start]
|
|
|
|
warning_alarms = entry_alarms(fires, WARNING_QUADRANTS)
|
|
shipped_metrics = _score_rule(
|
|
warning_alarms, evaluable_events, dates, horizon, evaluable_sessions
|
|
)
|
|
shipped_events = shipped_metrics.pop("per_event")
|
|
|
|
# --- the fitted variant, kept for continuity -------------------------
|
|
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]
|
|
warn_threshold = _percentile(train_values, WARN_PERCENTILE)
|
|
if warn_threshold is None:
|
|
return {"available": False, "reason": "insufficient warning history"}
|
|
holdout_events = [index for index in all_event_indices if index >= split]
|
|
fitted_alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
|
|
holdout_sessions = max(1, len(dates) - split)
|
|
fitted_metrics = _score_rule(
|
|
fitted_alarms, holdout_events, dates, horizon, holdout_sessions
|
|
)
|
|
fitted_events = fitted_metrics.pop("per_event")
|
|
reliability = _reliability(dates, split, backing, len(all_events), len(holdout_events))
|
|
|
|
# --- ablations and baselines, all on fixed thresholds ----------------
|
|
# Fitted thresholds are deliberately excluded here: a threshold fitted on the
|
|
# full sample would have lookahead the shipped rule does not, and one fitted
|
|
# on a training split could only be scored on the four holdout events. Fixed
|
|
# constants keep every row on the same events over the same sessions.
|
|
state_series = {
|
|
session: row["state"] for session, row in rows.items() if row["state"] is not None
|
|
}
|
|
vix_indicator = {
|
|
session: value
|
|
for session in dates
|
|
if (value := rms._value_asof(vix_series, session)) is not None
|
|
}
|
|
# The fundamental channel is categorical and never enters a score, so it is
|
|
# compared as its own rule and as a confluence gate rather than tuned as a
|
|
# weight. With an empty observation series both are unmeasurable, and say so.
|
|
fundamental_alarms = adverse_episodes(rows, dates, evaluable_start)
|
|
confluence_alarms = confluence_episodes(warning_alarms, rows, dates)
|
|
# Coverage-matched denominators. These rules only existed on the sessions the
|
|
# channel had usable context, so scoring them over the whole window would
|
|
# report an exposure they never had -- and one day of coverage would render
|
|
# as 0/10.
|
|
fundamental_events = covered_events(evaluable_events, rows, dates, horizon)
|
|
fundamental_sessions = eligible_sessions(rows, dates, evaluable_start)
|
|
fundamental_measurable = len(fundamental_events) >= MIN_EVENTS_FOR_CONFIDENCE
|
|
comparison = [
|
|
_rule_row(
|
|
"fundamental_adverse", "Fundamental context turns adverse", "fundamental",
|
|
"The third channel on its own: transitions into an adverse capex / "
|
|
"earnings-reaction state, with no market input at all.",
|
|
fundamental_alarms, fundamental_events, dates, horizon, fundamental_sessions,
|
|
measurable=fundamental_measurable,
|
|
),
|
|
_rule_row(
|
|
"confluence", "Confluence: Warning crossing while adverse", "fundamental",
|
|
"The shipped market crossing, kept only when the fundamental channel "
|
|
"agrees. Answers whether requiring agreement buys precision, at what "
|
|
"cost in recall.",
|
|
confluence_alarms, fundamental_events, dates, horizon, fundamental_sessions,
|
|
measurable=fundamental_measurable,
|
|
),
|
|
_rule_row(
|
|
"market_over_covered", "Quadrant alert, covered window only", "fundamental",
|
|
"The shipped market rule scored on exactly the events, sessions and "
|
|
"alarms the two rows above were scored on. Without it, any difference "
|
|
"between them and the headline could be the window rather than the "
|
|
"channel.",
|
|
# Alarms are restricted to the covered window too: counting crossings
|
|
# that fired when the channel had no context would compare the market
|
|
# rule's full exposure against the channel's partial one.
|
|
[
|
|
index
|
|
for index in warning_alarms
|
|
if index >= evaluable_start
|
|
and bool((rows.get(dates[index]) or {}).get("fundamental_usable"))
|
|
],
|
|
fundamental_events, dates, horizon, fundamental_sessions,
|
|
measurable=fundamental_measurable,
|
|
),
|
|
_rule_row(
|
|
"quadrant_stress_entry", "Quadrant alert, both axes high", "ablation",
|
|
"The same replay, recording only entries into the both-high quadrant. "
|
|
"State is coincident by construction, so requiring it should convert "
|
|
"leads into confirmations.",
|
|
entry_alarms(fires, STRESS_QUADRANT),
|
|
evaluable_events, dates, horizon, evaluable_sessions,
|
|
),
|
|
_rule_row(
|
|
"warning_bare", f"Warning >= {QUAD_Y_DIV:.0f} (bare crossing)", "ablation",
|
|
"The shipped divider with none of the quadrant machinery: no State "
|
|
"condition, no hysteresis, no confirmation, no cooldown.",
|
|
alarm_episodes(warning, dates, QUAD_Y_DIV, start_index=evaluable_start),
|
|
evaluable_events, dates, horizon, evaluable_sessions,
|
|
),
|
|
_rule_row(
|
|
"state_bare", f"State >= {QUAD_X_DIV:.0f} (bare crossing)", "ablation",
|
|
"The coincident axis alone. State measures stress that has already "
|
|
"arrived, so a competitive lead here would be surprising.",
|
|
alarm_episodes(state_series, dates, QUAD_X_DIV, start_index=evaluable_start),
|
|
evaluable_events, dates, horizon, evaluable_sessions,
|
|
),
|
|
_rule_row(
|
|
"smh_below_50dma", f"{leader} below its {BASELINE_SMA_WINDOW}-DMA", "baseline",
|
|
"The crudest possible trend rule, and free.",
|
|
alarm_episodes(
|
|
below_average_series(benchmark, BASELINE_SMA_WINDOW), dates,
|
|
50.0, start_index=evaluable_start,
|
|
),
|
|
evaluable_events, dates, horizon, evaluable_sessions,
|
|
),
|
|
_rule_row(
|
|
"vix_level", f"VIX >= {BASELINE_VIX_LEVEL:.0f}", "baseline",
|
|
"The market's own risk gauge, unweighted and unmodelled.",
|
|
alarm_episodes(
|
|
vix_indicator, dates, BASELINE_VIX_LEVEL, start_index=evaluable_start
|
|
),
|
|
evaluable_events, dates, horizon, evaluable_sessions,
|
|
),
|
|
]
|
|
|
|
null_model = _null_model(
|
|
len(warning_alarms), evaluable_events, dates, horizon,
|
|
evaluable_start, shipped_metrics["events_warned"],
|
|
# Passed rather than defaulted: a default argument binds the constant at
|
|
# import, so overriding it (in tests) would silently do nothing.
|
|
draws=NULL_DRAWS, seed=NULL_SEED,
|
|
)
|
|
eras = _era_split(
|
|
warning_alarms, evaluable_events, dates, horizon, evaluable_start, credit_from
|
|
)
|
|
basket_asof = date.fromisoformat(config["basket_asof"])
|
|
retrospective = dates[evaluable_start] < basket_asof
|
|
evaluation = "exploratory" if retrospective else "holdout"
|
|
lead_text = (
|
|
f"median lead {shipped_metrics['median_lead_days']:.0f} sessions"
|
|
if shipped_metrics["median_lead_days"] is not None
|
|
else "no successful warning lead"
|
|
)
|
|
summary = (
|
|
f"{evaluation.capitalize()} replay of the shipped quadrant alert over "
|
|
f"{evaluable_sessions} sessions: it entered Warning-high territory ahead of "
|
|
f"{shipped_metrics['events_warned']} of {shipped_metrics['events']} 10% "
|
|
f"corrections, with {shipped_metrics['false_alarms_per_year']:.1f} false "
|
|
f"alarms/year and {lead_text}. Its dividers are fixed constants rather than "
|
|
f"fitted, so there is no training split and every detected correction is "
|
|
f"evaluable — compare it against the ablations and baselines below before "
|
|
f"reading the ratio as good or bad."
|
|
)
|
|
|
|
report = {
|
|
"available": True,
|
|
"schema": STUDY_SCHEMA,
|
|
"methodology": rms.METHODOLOGY,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"evaluation": evaluation,
|
|
"summary": summary,
|
|
"params": {
|
|
"benchmark": leader,
|
|
"outcome": "10% correction from trailing 52-week high",
|
|
"event_threshold_pct": threshold_pct,
|
|
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
|
|
"horizon_days": horizon,
|
|
"credit_sensor_from": credit_from.isoformat() if credit_from else None,
|
|
"basket_hash": rms._basket_hash(config["breadth_basket"]),
|
|
"basket_asof": config["basket_asof"],
|
|
},
|
|
# The channel's actual exposure, which is what its rows are scored on.
|
|
# The series starts empty -- the observation lived in a single
|
|
# overwritten settings slot until 2026-08-12 -- and it accumulates one
|
|
# observation at a time, so for a long while these rows are unmeasurable
|
|
# rather than unsuccessful. Stating the exposure is what stops the table
|
|
# inventing a failed result out of a thin one.
|
|
"fundamental_coverage": {
|
|
"observations": len(observations),
|
|
"sessions_eligible": fundamental_sessions,
|
|
"evaluable_sessions": evaluable_sessions,
|
|
"events_covered": len(fundamental_events),
|
|
"events_evaluable": len(evaluable_events),
|
|
"minimum_events": MIN_EVENTS_FOR_CONFIDENCE,
|
|
"measurable": fundamental_measurable,
|
|
},
|
|
"sample": {
|
|
"start": dates[0].isoformat(),
|
|
"end": dates[-1].isoformat(),
|
|
"sessions": len(dates),
|
|
# Not "test_start": the shipped rule fits nothing, so this is where
|
|
# the baseline seeds and every rule becomes measurable, not where a
|
|
# holdout begins. The fitted variant's split lives under "fitted".
|
|
"evaluable_from": dates[evaluable_start].isoformat(),
|
|
"evaluable_sessions": evaluable_sessions,
|
|
"events_detected": len(all_events),
|
|
"events_evaluable": len(evaluable_events),
|
|
},
|
|
"shipped": {
|
|
"rule": {
|
|
"state_divider": QUAD_X_DIV,
|
|
"warning_divider": QUAD_Y_DIV,
|
|
"margin": QUAD_MARGIN,
|
|
"confirm_sessions": 2,
|
|
"cooldown_days": QUAD_COOLDOWN_DAYS,
|
|
"entry": "Warning-high quadrant (early warning or active stress)",
|
|
},
|
|
"metrics": shipped_metrics,
|
|
"events": shipped_events,
|
|
"quadrant_changes": len(fires),
|
|
"fires": fires,
|
|
"by_era": eras,
|
|
},
|
|
"comparison": comparison,
|
|
"null_model": null_model,
|
|
"fitted": {
|
|
"params": {
|
|
"train_fraction": TRAIN_FRACTION,
|
|
"warn_percentile": WARN_PERCENTILE,
|
|
"warn_threshold": round(warn_threshold, 1),
|
|
},
|
|
"sample": {
|
|
"train_end": dates[split - 1].isoformat(),
|
|
"test_start": dates[split].isoformat(),
|
|
"holdout_sessions": holdout_sessions,
|
|
},
|
|
"metrics": fitted_metrics,
|
|
"events": fitted_events,
|
|
},
|
|
"reliability": reliability,
|
|
"recent_breadth": [
|
|
{"date": d.isoformat(), "breadth": breadth[d], "warning": warning.get(d)}
|
|
for d in dates[-90:]
|
|
if d in breadth
|
|
],
|
|
}
|
|
logger.info(json.dumps({
|
|
"event": "regime_event_study_complete",
|
|
"evaluation": evaluation,
|
|
"shipped_events": shipped_metrics["events"],
|
|
"shipped_warned": shipped_metrics["events_warned"],
|
|
"shipped_false_alarms_per_year": shipped_metrics["false_alarms_per_year"],
|
|
"quadrant_changes": len(fires),
|
|
"fitted_events": fitted_metrics["events"],
|
|
"fitted_warned": fitted_metrics["events_warned"],
|
|
"null_p_at_least_observed": (null_model or {}).get("p_at_least_observed"),
|
|
"underpowered": reliability["underpowered"],
|
|
"sensor_coverage_mismatch": reliability["sensor_coverage_mismatch"],
|
|
}))
|
|
return report
|
|
|
|
|
|
async def run_and_store(db: AsyncSession) -> dict:
|
|
report = await run_event_study(db)
|
|
await update_setting(db, KEY_REPORT, json.dumps(report))
|
|
return report
|
|
|
|
|
|
async def get_event_study_report(db: AsyncSession) -> dict | None:
|
|
setting = await settings_store.get_setting(db, KEY_REPORT)
|
|
if setting is None:
|
|
return None
|
|
try:
|
|
report = json.loads(setting.value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if report.get("methodology") != rms.METHODOLOGY:
|
|
return None
|
|
# A pre-replay report parses fine and carries the current methodology, so the
|
|
# shape has to be checked separately or the panel renders a headline-less v4.
|
|
return report if report.get("schema") == STUDY_SCHEMA else None
|