feat: replace regime monitor with v2 methodology

This commit is contained in:
2026-07-15 09:02:56 +02:00
parent fd21067a40
commit 1d5b1489be
17 changed files with 1599 additions and 1535 deletions
+4 -4
View File
@@ -8,12 +8,12 @@ from app.database import Base
class RegimeSnapshot(Base):
"""Daily snapshot of the AI/Tech regime-change index.
"""Daily point-in-time snapshot of the AI/Tech Regime Monitor.
One row per calendar date (unique). ``breakdown_json`` holds the full
per-signal breakdown plus the raw inputs, so reads need no recomputation and
the 7/30-day trend is just a query over ``total_score``. Decoupled from the
rest of the platform: nothing reads this to gate or score trades.
``breakdown_json`` is authoritative for v2 State, Warning, source dates,
coverage, and fixed-basket metadata. ``total_score``/``band`` retain the v2
State reading for schema compatibility. Nothing reads this to gate trades.
"""
__tablename__ = "regime_snapshots"
+18 -12
View File
@@ -1,7 +1,7 @@
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_access, require_admin
@@ -40,12 +40,18 @@ async def backtest_report(
class RegimeConfigUpdate(BaseModel):
weights: dict[str, float] | None = None
alert_threshold: float | None = None
tickers: dict | None = None
leader_weight: float | None = None
rs_lookback: int | None = None
fundamental_staleness_days: int | None = None
breadth_basket: list[str] | None = Field(default=None, min_length=20, max_length=100)
fundamental_staleness_days: int | None = Field(default=None, ge=30, le=180)
@field_validator("breadth_basket")
@classmethod
def normalise_basket(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
cleaned = [symbol.strip().upper().replace(".", "-") for symbol in value if symbol.strip()]
if len(cleaned) != len(set(cleaned)):
raise ValueError("breadth basket symbols must be unique")
return cleaned
class RegimeFundamentalsUpdate(BaseModel):
@@ -59,7 +65,7 @@ async def regime_monitor(
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Latest AI/Tech regime-change index (0-100) + per-signal breakdown + trend."""
"""Latest v2 State and Warning risk-thermometer readings."""
data = await regime_monitor_service.get_regime_monitor(db)
return APIEnvelope(status="success", data=data)
@@ -69,7 +75,7 @@ async def regime_config(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Editable weights / thresholds / ticker lists for the regime monitor."""
"""Editable fixed breadth basket and fundamental freshness window."""
data = await regime_monitor_service.get_regime_config(db)
return APIEnvelope(status="success", data=data)
@@ -80,7 +86,7 @@ async def update_regime_config(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Merge the supplied fields into the stored regime-monitor config."""
"""Update the deliberately small v2 operator configuration."""
updates = body.model_dump(exclude_none=True)
data = await regime_monitor_service.update_regime_config(db, updates)
return APIEnvelope(status="success", data=data)
@@ -133,10 +139,10 @@ async def regime_event_study(
@router.get("/regime/history", response_model=APIEnvelope)
async def regime_history(
days: int = Query(default=400, ge=7, le=2000),
days: int = Query(default=800, ge=7, le=2000),
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Daily history of the index / early-warning / combined scores (for the chart)."""
"""Point-in-time v2 State/Warning history. Legacy rows are excluded."""
data = await regime_monitor_service.get_regime_history(db, days=days)
return APIEnvelope(status="success", data=data)
+15 -3
View File
@@ -1001,12 +1001,20 @@ async def compute_regime_monitor() -> None:
result = await update_regime_monitor(db)
state = result.get("state") or {}
warning = result.get("warning") or {}
_runtime_progress(job_name, processed=1, total=1)
_runtime_finish(
job_name, "completed", processed=1, total=1,
message=f"Index: {result.get('total_score')} ({result.get('band')})",
message=f"State: {state.get('score')} · Warning: {warning.get('score')}",
)
_log_event(
logging.INFO,
"job_complete",
job=job_name,
state=state.get("score"),
warning=warning.get("score"),
)
_log_event(logging.INFO, "job_complete", job=job_name, score=result.get("total_score"))
except Exception as exc:
_runtime_finish(job_name, "error", processed=0, total=1, message=str(exc))
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
@@ -1086,7 +1094,11 @@ async def run_event_study_job() -> None:
_runtime_progress(job_name, processed=1, total=1)
if report.get("available"):
msg = f"{len(report.get('events', []))} events, lead Δ {report.get('lead_delta_days')}d"
metrics = report.get("metrics") or {}
msg = (
f"{metrics.get('events_warned', 0)}/{metrics.get('events', 0)} warned, "
f"{metrics.get('false_alarms_per_year', 0)} false alarms/year"
)
else:
msg = report.get("reason", "no data")
_runtime_finish(job_name, "completed", processed=1, total=1, message=msg)
+99 -40
View File
@@ -58,7 +58,9 @@ _BOOL_DEFAULTS = {
KEY_SR: True,
KEY_SCORE_DROP: True,
KEY_DIGEST: True,
KEY_REGIME_QUADRANT: True,
# Experimental human-facing thermometer: opt in explicitly. Existing stored
# true values remain true; only missing/reset configurations default off.
KEY_REGIME_QUADRANT: False,
KEY_TRADE_CLOSED: True,
}
@@ -90,19 +92,19 @@ SIGNAL_BUNDLE_SECTIONS = (
)
SIGNAL_BUNDLE_MAX_CHARS = 3900 # Telegram limit is 4096; keep room for HTML parsing
# Regime quadrant-change alert: (regime index x early-warning) quadrant.
# Regime quadrant-change alert: (State x Warning) quadrant.
# 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 = 40.0 # regime index divider (matches the frontend quadrant)
QUAD_Y_DIV = 60.0 # early-warning divider
QUAD_X_DIV = 60.0 # v2 State divider (backend response is authoritative)
QUAD_Y_DIV = 60.0 # v2 Warning divider
QUAD_MARGIN = 5.0 # half-width of the hysteresis deadband around each divider
QUAD_COOLDOWN_DAYS = 3 # min days between quadrant-change alerts
QUAD_LABELS = {
"1": "① Hot & brittle",
"2": "② Transition",
"3": "③ Healthy & broad",
"4": "④ Real downturn",
"1": "Early warning",
"2": "Active stress",
"3": "Healthy",
"4": "Stressed / stabilizing",
}
AlertItem = tuple[str, str, str] # alert_type, dedup_key, text
@@ -693,49 +695,65 @@ def _closed_trade_bundle(
def _bools_to_quadrant(x_high: bool, y_high: bool) -> str:
if y_high:
return "2" if x_high else "1" # ② Transition / ① Hot & brittle
return "4" if x_high else "3" # ④ Real downturn / Healthy & broad
return "2" if x_high else "1" # Active stress / Early warning
return "4" if x_high else "3" # Stressed/stabilizing / Healthy
def _quadrant_to_bools(q: str) -> tuple[bool, bool]:
return {"1": (False, True), "2": (True, True), "3": (False, False), "4": (True, False)}[q]
def _classify_quadrant(x: float, y: float, prev: str | None, margin: float = QUAD_MARGIN) -> str:
"""Quadrant of (regime index x, early warning y), with per-axis hysteresis.
def _classify_quadrant(
x: float,
y: float,
prev: str | None,
margin: float = QUAD_MARGIN,
x_div: float = QUAD_X_DIV,
y_div: float = QUAD_Y_DIV,
) -> str:
"""Quadrant of (State x, Warning y), with per-axis hysteresis.
Each axis only flips once the value crosses its divider by ``margin`` in the
new direction, so a point parked on a divider keeps its current quadrant
instead of flip-flopping. ``prev`` None means a fresh (no-hysteresis) classify.
"""
if prev is None:
return _bools_to_quadrant(x >= QUAD_X_DIV, y >= QUAD_Y_DIV)
return _bools_to_quadrant(x >= x_div, y >= y_div)
px, py = _quadrant_to_bools(prev)
x_high = (x >= QUAD_X_DIV - margin) if px else (x >= QUAD_X_DIV + margin)
y_high = (y >= QUAD_Y_DIV - margin) if py else (y >= QUAD_Y_DIV + margin)
x_high = (x >= x_div - margin) if px else (x >= x_div + margin)
y_high = (y >= y_div - margin) if py else (y >= y_div + margin)
return _bools_to_quadrant(x_high, y_high)
def _quadrant_log_key(q: str, x: float, y: float) -> str:
return f"{q}:{x:.1f}:{y:.1f}"
def _quadrant_log_key(q: str, x: float, y: float, basket_hash: str | None = None) -> str:
return f"{basket_hash or 'legacy'}:{q}:{x:.1f}:{y:.1f}"
def _parse_quadrant_log_key(key: str | None) -> tuple[str | None, float | None, float | None]:
def _parse_quadrant_log_key(
key: str | None,
) -> tuple[str | None, str | None, float | None, float | None]:
if not key:
return None, None, None
return None, None, None, None
parts = key.split(":")
q = parts[0]
if parts[0] in QUAD_LABELS:
basket_hash, q, values = None, parts[0], parts[1:]
elif len(parts) >= 2:
basket_hash, q, values = parts[0], parts[1], parts[2:]
else:
return None, None, None, None
if q not in QUAD_LABELS:
return None, None, None
if len(parts) >= 3:
return None, None, None, None
if len(values) >= 2:
try:
return q, float(parts[1]), float(parts[2])
return basket_hash, q, float(values[0]), float(values[1])
except ValueError:
pass
return q, None, None
return basket_hash, q, None, None
async def _last_quadrant(db: AsyncSession) -> tuple[str | None, float | None, float | None, datetime | None]:
async def _last_quadrant(
db: AsyncSession,
) -> tuple[str | None, str | None, float | None, float | None, datetime | None]:
"""Most recently logged quadrant (and when), our baseline for change + cooldown."""
result = await db.execute(
select(AlertLog.dedup_key, AlertLog.created_at)
@@ -745,9 +763,9 @@ async def _last_quadrant(db: AsyncSession) -> tuple[str | None, float | None, fl
)
row = result.first()
if not row:
return None, None, None, None
prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0])
return prev_q, prev_x, prev_y, row[1]
return None, None, None, None, None
basket_hash, prev_q, prev_x, prev_y = _parse_quadrant_log_key(row[0])
return basket_hash, prev_q, prev_x, prev_y, row[1]
async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
@@ -758,25 +776,64 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
cooldown has elapsed. The dispatch loop logs the new quadrant on send, which
becomes the next baseline and resets the cooldown clock.
"""
from app.services.regime_monitor_service import get_regime_monitor
from app.services.regime_monitor_service import get_regime_history, get_regime_monitor
data = await get_regime_monitor(db)
if not data.get("available"):
return []
x = data.get("total_score")
y = (data.get("early_warning") or {}).get("score")
state = data.get("state") or {}
warning = data.get("warning") or {}
x = state.get("score")
y = warning.get("score")
if x is None or y is None:
return []
prev, prev_x, prev_y, prev_time = await _last_quadrant(db)
if prev is None:
_log_alert(db, QUAD_TYPE, _quadrant_log_key(_classify_quadrant(x, y, None), x, y)) # seed, no alert
quality = data.get("data_quality") or {}
if (
float(state.get("coverage") or 0) < 75
or float(warning.get("coverage") or 0) < 75
or not quality.get("is_fresh")
):
return []
new_q = _classify_quadrant(x, y, prev)
quadrant_cfg = data.get("quadrant_config") or {}
x_div = float(quadrant_cfg.get("state_divider", QUAD_X_DIV))
y_div = float(quadrant_cfg.get("warning_divider", QUAD_Y_DIV))
margin = float(quadrant_cfg.get("margin", QUAD_MARGIN))
basket_hash = str((data.get("basket") or {}).get("hash") or "unknown")
prev_hash, prev, prev_x, prev_y, prev_time = await _last_quadrant(db)
if prev is None or prev_hash != basket_hash:
seed = _classify_quadrant(x, y, None, margin, x_div, y_div)
_log_alert(db, QUAD_TYPE, _quadrant_log_key(seed, x, y, basket_hash))
return []
new_q = _classify_quadrant(x, y, prev, margin, x_div, y_div)
if new_q == prev:
return []
history = await get_regime_history(db, days=14)
valid = [
point for point in history
if point.get("state") is not None
and point.get("warning") is not None
and float(point.get("state_coverage") or 0) >= 75
and float(point.get("warning_coverage") or 0) >= 75
]
if len(valid) < 2:
return []
prior = valid[-2]
prior_q = _classify_quadrant(
float(prior["state"]),
float(prior["warning"]),
prev,
margin,
x_div,
y_div,
)
if prior_q != new_q:
return []
if prev_time is not None:
if prev_time.tzinfo is None:
prev_time = prev_time.replace(tzinfo=timezone.utc)
@@ -785,17 +842,19 @@ async def _collect_regime_quadrant(db: AsyncSession) -> list[tuple[str, str]]:
if prev_x is not None and prev_y is not None:
metrics = (
f"regime {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · "
f"early-warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})"
f"State {prev_x:.0f}{x:.0f} ({x - prev_x:+.0f}) · "
f"Warning {prev_y:.0f}{y:.0f} ({y - prev_y:+.0f})"
)
else:
metrics = f"regime {x:.0f} · early-warning {y:.0f}"
metrics = f"State {x:.0f} · Warning {y:.0f}"
text = (
f"🧭 <b>Regime quadrant change</b>\n"
f"{QUAD_LABELS.get(prev, prev)}{QUAD_LABELS.get(new_q, new_q)}\n"
f"{metrics}"
f"{metrics}\n"
f"coverage: state {state.get('coverage'):.0f}% / warning {warning.get('coverage'):.0f}%\n"
f"<i>Risk thermometer - not a trade signal.</i>"
)
return [(_quadrant_log_key(new_q, x, y), text)]
return [(_quadrant_log_key(new_q, x, y, basket_hash), text)]
# ---------------------------------------------------------------------------
+50 -21
View File
@@ -1,18 +1,19 @@
"""Market-breadth early-warning indicator (from the stored universe OHLCV).
"""Market-breadth state and early-warning indicators.
Breadth is a genuinely *leading* construct: a few mega-caps can keep an index
rising while participation narrows underneath — the classic pre-top divergence.
We measure it from the OHLCV we already store for the whole universe, so it costs
no new data source.
V2 measures an explicit, frozen basket rather than every ticker currently stored
in the database. That keeps the live series reproducible when the wider product
universe changes.
Two layers:
- breadth = % of the universe trading above its own 200-DMA (0-100).
- divergence = an early-warning score (0-100, high = fragile): the benchmark
price rising *while* breadth falls, plus a nudge for already-low breadth.
price holding/rising *while* breadth falls. Absolute low breadth stays in the
State index so it is not counted twice.
This module only *computes* the indicator. It is deliberately NOT wired into the
live regime index yet — the event study measures whether it actually leads before
it earns any weight.
The live monitor uses the breadth level in State and the pure divergence in
Warning. The event study evaluates the latter chronologically.
"""
from __future__ import annotations
@@ -31,9 +32,9 @@ logger = logging.getLogger(__name__)
Series = list[tuple[date, float]]
def _breadth_from_closes(
def _breadth_with_counts(
closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20
) -> dict[date, float]:
) -> tuple[dict[date, float], dict[date, int]]:
"""Pure core: % of symbols above their own rolling SMA(window), per date.
Each symbol's SMA is computed once with a sliding sum (O(bars)); dates with
@@ -55,11 +56,20 @@ def _breadth_from_closes(
entry[1] += 1
if closes[i] > sma:
entry[0] += 1
return {
values = {
d: round(above / total * 100.0, 2)
for d, (above, total) in counts.items()
if total >= min_tickers
}
eligible = {d: total for d, (_, total) in counts.items() if total >= min_tickers}
return values, eligible
def _breadth_from_closes(
closes_by_symbol: dict[str, Series], window: int = 200, min_tickers: int = 20
) -> dict[date, float]:
"""Compatibility wrapper returning only the breadth percentage series."""
return _breadth_with_counts(closes_by_symbol, window, min_tickers)[0]
def compute_divergence_series(
@@ -67,10 +77,10 @@ def compute_divergence_series(
) -> dict[date, float]:
"""Early-warning score (0-100, high = fragile) per date.
Fragility rises when the benchmark price climbs over ``lookback`` days while
breadth deteriorates over the same window, and is nudged up when the absolute
breadth level is already low. It is the *divergence* (not the level) that
makes this leading.
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.
"""
bench = {d: c for d, c in benchmark_closes}
common = sorted(d for d in bench if d in breadth)
@@ -82,14 +92,19 @@ def compute_divergence_series(
continue
price_ret = (bench[d] / price_past - 1.0) * 100.0 # %
breadth_chg = breadth[d] - breadth[d0] # percentage points
raw = price_ret - breadth_chg # price up & breadth down -> large
score = 50.0 + raw * 2.0 + (50.0 - breadth[d]) * 0.4
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)))
return out
async def _load_universe_closes(db: AsyncSession) -> dict[str, Series]:
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
async def _load_universe_closes(
db: AsyncSession, symbols: list[str] | None = None
) -> dict[str, Series]:
stmt = select(Ticker).order_by(Ticker.symbol)
if symbols is not None:
stmt = stmt.where(Ticker.symbol.in_(symbols))
result = await db.execute(stmt)
closes_by_symbol: dict[str, Series] = {}
for ticker in result.scalars().all():
try:
@@ -103,13 +118,27 @@ async def _load_universe_closes(db: AsyncSession) -> dict[str, Series]:
async def compute_breadth_series(
db: AsyncSession, window: int = 200, min_tickers: int = 20
db: AsyncSession,
window: int = 200,
min_tickers: int = 20,
symbols: list[str] | None = None,
) -> dict[date, float]:
"""Historical breadth series across the stored universe (for the event study)."""
closes_by_symbol = await _load_universe_closes(db)
"""Historical breadth series across an explicit basket (or all stored names)."""
closes_by_symbol = await _load_universe_closes(db, symbols)
return _breadth_from_closes(closes_by_symbol, window, min_tickers)
async def compute_breadth_details(
db: AsyncSession,
symbols: list[str],
window: int = 200,
min_tickers: int = 20,
) -> tuple[dict[date, float], dict[date, int]]:
"""Breadth values plus the qualifying-member count for snapshot metadata."""
closes_by_symbol = await _load_universe_closes(db, symbols)
return _breadth_with_counts(closes_by_symbol, window, min_tickers)
async def compute_breadth_today(db: AsyncSession) -> float | None:
"""Latest breadth reading (thin wrapper, for future live use)."""
series = await compute_breadth_series(db)
+191 -239
View File
@@ -1,21 +1,9 @@
"""Event study: does a candidate indicator actually *lead* regime breaks?
"""Compact chronological validation for the Regime Monitor warning score.
This is a backtest-style measurement, but the unit of analysis is **events**
(historical drawdowns), not trades. For each candidate indicator it answers:
- how many days of warning did it give before the break (event-centered)?
- at what false-alarm cost (signal-centered precision/recall vs. the base rate)?
It compares the breadth-divergence early-warning candidate against a deterministic
**coincident** price composite (the existing regime price sub-scores), so you can
see whether the candidate crosses *earlier*. Everything is price/breadth only —
no LLM/FRED — so the result is reproducible.
Honest caveat: with only a handful of real drawdowns in ~5y, the sample is tiny
and the numbers are noisy. Read the median lead time as an order of magnitude, and
do NOT overfit thresholds to this history.
Report is cached in a SystemSetting (mirrors ``backtest_service``); a manual job
(Admin → Jobs) drives it.
The study calls its outcome a 10% correction, uses the first 70% of sessions to
freeze an 80th-percentile warning threshold, and reports alarm episodes only on
the final 30%. It is still labelled exploratory while the fixed breadth basket
is reconstructed before its freeze date.
"""
from __future__ import annotations
@@ -34,304 +22,268 @@ logger = logging.getLogger(__name__)
KEY_REPORT = "regime_event_study"
# Defaults. The 15% threshold gave only 2 events in 5y (statistically useless),
# so the default is lower with a cooldown-based dedup to surface more, cleaner
# events. Each indicator "warns" at its OWN 80th percentile rather than a shared
# absolute level, so the leading vs. coincident comparison is fair across scales.
EVENT_THRESHOLD_PCT = 10.0 # drawdown from the 52w high that counts as a "break"
COOLDOWN_DAYS = 40 # min trading days between event onsets (dedup)
DRAWDOWN_LOOKBACK = 252 # 52-week trailing high
HORIZON_DAYS = 20 # signal-centered prediction horizon
WARN_PERCENTILE = 80.0 # each indicator warns at its own Nth percentile
PRE, POST = 60, 20 # event-centered window (trading days)
EVENT_THRESHOLD_PCT = 10.0
EVENT_COOLDOWN_DAYS = 40
DRAWDOWN_LOOKBACK = 252
HORIZON_DAYS = 20
WARN_PERCENTILE = 80.0
TRAIN_FRACTION = 0.70
def _median(values: list[float]) -> float | None:
if not values:
return None
s = sorted(values)
n = len(s)
mid = n // 2
return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0
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:
"""Linear-interpolated percentile of the non-None values."""
vals = sorted(v for v in values if v is not None)
if not vals:
ordered = sorted(v for v in values if v is not None)
if not ordered:
return None
k = (len(vals) - 1) * (pct / 100.0)
lo = int(k)
hi = min(lo + 1, len(vals) - 1)
return vals[lo] + (vals[hi] - vals[lo]) * (k - lo)
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)
# ---------------------------------------------------------------------------
# Event detection
# ---------------------------------------------------------------------------
def detect_events(
closes: list[float],
dates: list[date],
threshold_pct: float = EVENT_THRESHOLD_PCT,
lookback: int = DRAWDOWN_LOOKBACK,
cooldown: int = COOLDOWN_DAYS,
cooldown: int = EVENT_COOLDOWN_DAYS,
) -> list[dict]:
"""Drawdown events: ``t0`` = a day the drawdown from the trailing 52w high
crosses up through ``threshold_pct`` (rising edge). De-duplicated by a
``cooldown`` of trading days, so a continuous decline counts once but distinct
drawdowns separated by a recovery each register."""
"""Rising-edge corrections from the trailing 52-week high."""
events: list[dict] = []
prev_dd = 0.0
previous_drawdown = 0.0
last_event = -10**9
for i in range(len(closes)):
window = closes[max(0, i - lookback + 1): i + 1]
hi = max(window)
dd = (hi - closes[i]) / hi * 100.0 if hi > 0 else 0.0
if dd >= threshold_pct and prev_dd < threshold_pct and (i - last_event) >= cooldown:
events.append({"date": dates[i].isoformat(), "index": i, "depth_pct": round(dd, 1)})
last_event = i
prev_dd = dd
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
# ---------------------------------------------------------------------------
# Event-centered: lead time + mean path
# ---------------------------------------------------------------------------
def _lead(indicator: dict[date, float], t0: int, dates: list[date], pre: int, threshold: float) -> int | None:
"""Earliest day within ``[t0-pre, t0]`` at which the indicator crosses
``threshold`` — i.e. how many days of warning before the event, or None."""
lead: int | None = None
for k in range(0, pre + 1):
idx = t0 - k
if idx < 0:
break
v = indicator.get(dates[idx])
if v is not None and v >= threshold:
lead = k # keep going: the largest k = earliest warning in the window
return lead
def event_centered(
def alarm_episodes(
indicator: dict[date, float],
events_idx: list[int],
dates: list[date],
pre: int = PRE,
post: int = POST,
threshold: float = 60.0,
) -> dict:
"""Align the indicator at each event's ``t0`` and measure how early it warned.
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
Lead time is measured against ``threshold`` (each indicator gets its own,
derived from its distribution). Also returns the cross-event mean path.
"""
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] = []
sums: dict[int, float] = {}
counts: dict[int, int] = {}
for t0 in events_idx:
lead = _lead(indicator, t0, dates, pre, threshold)
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:
leads.append(lead)
for rel in range(-pre, post + 1):
idx = t0 + rel
if 0 <= idx < len(dates):
v = indicator.get(dates[idx])
if v is not None:
sums[rel] = sums.get(rel, 0.0) + v
counts[rel] = counts.get(rel, 0) + 1
mean_path = [
{"rel_day": rel, "value": round(sums[rel] / counts[rel], 1)} for rel in sorted(sums)
]
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),
"events_with_signal": len(leads),
"events_total": len(events_idx),
"warn_threshold": round(threshold, 1),
"mean_path": mean_path,
"per_event": per_event,
}
# ---------------------------------------------------------------------------
# Signal-centered: precision / recall vs. base rate
# ---------------------------------------------------------------------------
def signal_centered(
indicator: dict[date, float],
events_idx: list[int],
def _warning_series(
prices: dict[str, rms.Series],
breadth_divergence: dict[date, float],
dates: list[date],
horizon: int = HORIZON_DAYS,
thresholds: list[float] | None = None,
) -> dict:
"""Treat ``indicator >= threshold`` as predicting a break within ``horizon``
days. Sweep thresholds → precision/recall/alarm count, plus the base rate."""
thresholds = thresholds or [50, 55, 60, 65, 70, 75, 80]
n = len(dates)
labels = [1 if any(i < e <= i + horizon for e in events_idx) else 0 for i in range(n)]
positives = sum(labels)
base_rate = positives / n if n else 0.0
rows: list[dict] = []
for th in thresholds:
tp = fp = fn = 0
for i in range(n):
v = indicator.get(dates[i])
if v is None:
continue
pred = v >= th
if pred and labels[i]:
tp += 1
elif pred and not labels[i]:
fp += 1
elif not pred and labels[i]:
fn += 1
precision = tp / (tp + fp) if (tp + fp) else None
recall = tp / (tp + fn) if (tp + fn) else None
rows.append({
"threshold": th,
"precision": round(precision, 3) if precision is not None else None,
"recall": round(recall, 3) if recall is not None else None,
"alarms": tp + fp,
})
return {"base_rate": round(base_rate, 3), "horizon_days": horizon, "rows": rows}
# ---------------------------------------------------------------------------
# Coincident baseline (deterministic price composite, reusing the regime sub-scores)
# ---------------------------------------------------------------------------
def _coincident_series(prices: dict[str, list], dates: list[date], config: dict) -> dict[date, float]:
"""Mean of the available price sub-scores (P1-P4) as-of each date — the
coincident baseline the leading candidate must beat on lead time."""
lw = float(config.get("leader_weight", 2.0))
lb = int(config.get("rs_lookback", 60))
t = config["tickers"]
smh_full = prices.get(t["leaders"][0], []) if t["leaders"] else []
qqq_full = prices.get(t["confirm"][0], []) if t["confirm"] else []
spy_full = prices.get(t["market"], [])
config: dict,
) -> dict[date, float]:
"""Technical Warning score used historically (fundamentals have no PIT history)."""
tickers = config["tickers"]
smh_full = prices.get(tickers["leaders"][0], [])
spy_full = prices.get(tickers["market"], [])
out: dict[date, float] = {}
for d in dates:
smh = rms._closes_asof(smh_full, d)
qqq = rms._closes_asof(qqq_full, d)
spy = rms._closes_asof(spy_full, d)
subs = [
rms.p1_trend_break(smh, qqq, lw),
rms.p2_death_cross(smh, qqq, lw),
rms.p3_drawdown(smh, qqq),
rms.p4_relative_strength(smh, spy, lb),
]
vals = [v for v in subs if v is not None]
if vals:
out[d] = round(sum(vals) / len(vals), 2)
for session in dates:
divergence = breadth_divergence.get(session)
relative = rms.p4_relative_strength(
rms._closes_asof(smh_full, session),
rms._closes_asof(spy_full, session),
)
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,
)
return out
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
async def run_event_study(
db: AsyncSession,
threshold_pct: float = EVENT_THRESHOLD_PCT,
horizon: int = HORIZON_DAYS,
cooldown: int = COOLDOWN_DAYS,
warn_percentile: float = WARN_PERCENTILE,
) -> dict:
"""Run the study: detect events on the benchmark, then measure breadth-divergence
vs. the coincident price composite. Best-effort; returns available=False on no data."""
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] if config["tickers"]["leaders"] else "SMH"
bench = sorted(prices.get(leader, []), key=lambda x: x[0])
if len(bench) < 260:
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 bench]
closes = [c for _, c in bench]
events = detect_events(closes, dates, threshold_pct, cooldown=cooldown)
events_idx = [e["index"] for e in events]
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)
warning = _warning_series(prices, divergence, dates, config)
breadth = await breadth_service.compute_breadth_series(db)
divergence = breadth_service.compute_divergence_series(breadth, bench)
coincident = _coincident_series(prices, dates, config)
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"}
# Each indicator warns at its OWN distribution's percentile, so a leading
# indicator isn't penalised for living on a different scale than the baseline.
warn = {
"breadth_divergence": _percentile(list(divergence.values()), warn_percentile) or 60.0,
"coincident_price": _percentile(list(coincident.values()), warn_percentile) or 60.0,
}
series_by_key = {"breadth_divergence": divergence, "coincident_price": coincident}
all_events = detect_events(closes, dates, threshold_pct)
holdout_events = [event["index"] for event in all_events if event["index"] >= split]
alarms = alarm_episodes(warning, dates, warn_threshold, start_index=split)
metrics = evaluate_alarms(alarms, holdout_events, dates, horizon)
holdout_sessions = max(1, len(dates) - split)
metrics["false_alarms_per_year"] = round(
metrics["false_alarms"] / (holdout_sessions / 252.0), 2
)
def _evaluate(series: dict[date, float], threshold: float) -> dict:
return {
**event_centered(series, events_idx, dates, threshold=threshold),
"signal": signal_centered(series, events_idx, dates, horizon),
}
indicators = {key: _evaluate(series_by_key[key], warn[key]) for key in series_by_key}
# Per-event comparison: which event, and each indicator's lead on THAT event —
# so a median over a tiny sample can't hide an apples-to-oranges comparison.
per_event = [
{
"date": e["date"],
"depth_pct": e["depth_pct"],
"breadth_lead": _lead(divergence, e["index"], dates, PRE, warn["breadth_divergence"]),
"coincident_lead": _lead(coincident, e["index"], dates, PRE, warn["coincident_price"]),
}
for e in events
]
bd = indicators["breadth_divergence"]["median_lead_days"]
cd = indicators["coincident_price"]["median_lead_days"]
lead_delta = (bd - cd) if (bd is not None and cd is not None) else None
recent_breadth = [
{"date": d.isoformat(), "breadth": breadth[d], "divergence": divergence.get(d)}
for d in dates[-90:]
if d in breadth
]
basket_asof = date.fromisoformat(config["basket_asof"])
retrospective = dates[split] < basket_asof
evaluation = "exploratory" if retrospective else "holdout"
lead_text = (
f"median lead {metrics['median_lead_days']:.0f} sessions"
if metrics["median_lead_days"] is not None
else "no successful warning lead"
)
summary = (
f"{evaluation.capitalize()} chronological test: warning episodes preceded "
f"{metrics['events_warned']}/{metrics['events']} 10% corrections; "
f"{metrics['events_missed']} missed, {metrics['false_alarms_per_year']:.1f} "
f"false alarms/year, {lead_text}."
)
per_event = metrics.pop("per_event")
report = {
"available": True,
"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,
"cooldown_days": cooldown,
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
"horizon_days": horizon,
"warn_percentile": warn_percentile,
"train_fraction": TRAIN_FRACTION,
"warn_percentile": WARN_PERCENTILE,
"warn_threshold": round(warn_threshold, 1),
"basket_hash": rms._basket_hash(config["breadth_basket"]),
"basket_asof": config["basket_asof"],
},
"events": events,
"indicators": indicators,
"per_event": per_event,
"lead_delta_days": lead_delta,
"recent_breadth": recent_breadth,
"sample": {
"start": dates[0].isoformat(),
"end": dates[-1].isoformat(),
"train_end": dates[split - 1].isoformat(),
"test_start": dates[split].isoformat(),
"sessions": len(dates),
"holdout_sessions": holdout_sessions,
},
"metrics": metrics,
"events": per_event,
"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": "event_study_complete", "events": len(events),
"breadth_lead": bd, "coincident_lead": cd,
"event": "regime_event_study_complete",
"evaluation": evaluation,
"events": metrics["events"],
"warned": metrics["events_warned"],
"false_alarms_per_year": metrics["false_alarms_per_year"],
}))
return report
async def run_and_store(db: AsyncSession) -> dict:
"""Run the event study and cache the report in a SystemSetting. Job entrypoint."""
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:
"""Return the last cached event-study report, or None if never run."""
setting = await settings_store.get_setting(db, KEY_REPORT)
if setting is None:
return None
try:
return json.loads(setting.value)
report = json.loads(setting.value)
except (TypeError, ValueError):
return None
return report if report.get("methodology") == rms.METHODOLOGY else None
File diff suppressed because it is too large Load Diff