feat: replace regime monitor with v2 methodology
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user