feat: breadth-divergence early-warning indicator + event study

Adds a leading-by-construction candidate and the harness to measure whether it
actually leads regime breaks, before any of it earns weight in the live index.

- breadth_service: % of the stored universe above its own 200-DMA + a divergence
  score (benchmark price up while breadth falls, nudged by low breadth). Genuinely
  leading because it keys on divergence, not level. Not wired into the live score.
- event_study_service: detect drawdown events on the benchmark, then measure each
  indicator's median lead time (event-centered) and precision/recall vs. the base
  rate (signal-centered). Compares breadth-divergence against the deterministic
  coincident price composite (reuses the regime price sub-scores). Price/breadth
  only — reproducible, no LLM/FRED.
- Manual "Event Study" job (Admin → Jobs), GET /regime/event-study, and an
  inline early-warning panel on the Regime tab with an honest small-sample caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 14:08:52 +02:00
parent ebff19940b
commit 824c15cf69
10 changed files with 719 additions and 2 deletions
+44
View File
@@ -38,6 +38,7 @@ from app.services.alert_service import dispatch_alerts
from app.services.backtest_service import run_and_store as run_backtest_and_store
from app.services.market_regime_service import update_market_regime
from app.services.regime_monitor_service import update_regime_monitor
from app.services.event_study_service import run_and_store as run_event_study_and_store
from app.services.outcome_service import evaluate_pending_setups
from app.services.rr_scanner_service import scan_all_tickers
from app.services.sentiment_provider_service import build_sentiment_provider
@@ -82,6 +83,7 @@ _JOB_NAMES = [
"alerts",
"market_regime",
"regime_monitor",
"event_study",
"backtest",
"daily_pipeline",
"intraday_pipeline",
@@ -871,6 +873,42 @@ async def run_backtest_job() -> None:
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
# ---------------------------------------------------------------------------
# Job: Event Study (manual)
# ---------------------------------------------------------------------------
async def run_event_study_job() -> None:
"""Measure indicator lead time vs. historical drawdowns and cache the report.
Manual only (never auto-fires) — it does a universe-wide OHLCV scan. Triggered
from Admin → Jobs when you want to re-run the early-warning measurement.
"""
job_name = "event_study"
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name, total=1)
try:
async with async_session_factory() as db:
if not await _is_job_enabled(db, job_name):
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=1, message="Disabled")
return
report = await run_event_study_and_store(db)
_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"
else:
msg = report.get("reason", "no data")
_runtime_finish(job_name, "completed", processed=1, total=1, message=msg)
_log_event(logging.INFO, "job_complete", job=job_name, events=len(report.get("events", [])))
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))
# ---------------------------------------------------------------------------
# Job: Ticker Universe Sync
# ---------------------------------------------------------------------------
@@ -1127,6 +1165,12 @@ def configure_scheduler(schedule_config: dict[str, str] | None = None) -> None:
id="data_backfill", name="Data Backfill (deep history)",
replace_existing=True, next_run_time=None,
)
# Event study: manual only (universe-wide scan); triggered from Admin → Jobs.
scheduler.add_job(
run_event_study_job, "interval", weeks=520,
id="event_study", name="Event Study",
replace_existing=True, next_run_time=None,
)
_log_event(logging.INFO, "scheduler_configured", timezone=tz, daily_pipeline={
"cron": cfg["schedule_daily_pipeline_cron"],