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:
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user