290 lines
9.9 KiB
Python
290 lines
9.9 KiB
Python
"""Compact chronological validation for the Regime Monitor warning score.
|
|
|
|
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
|
|
|
|
import json
|
|
import logging
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
KEY_REPORT = "regime_event_study"
|
|
|
|
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
|
|
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 _warning_series(
|
|
prices: dict[str, rms.Series],
|
|
breadth_divergence: dict[date, float],
|
|
dates: list[date],
|
|
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 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
|
|
|
|
|
|
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)
|
|
warning = _warning_series(prices, divergence, 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"}
|
|
|
|
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
|
|
)
|
|
|
|
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,
|
|
"event_cooldown_days": EVENT_COOLDOWN_DAYS,
|
|
"horizon_days": horizon,
|
|
"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"],
|
|
},
|
|
"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": "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:
|
|
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
|
|
return report if report.get("methodology") == rms.METHODOLOGY else None
|