feat: sharpen the event study — more events, fair baseline, per-event view
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 26s

The first run gave only 2 events (N=2 is anecdote, not evidence) and an unfairly
weak coincident baseline, so the +42d lead couldn't be trusted. This makes the
measurement meaningful:

- More, cleaner events: default drawdown threshold 15%→10%, and dedup switched
  from "recover to the high" to a rising-edge + cooldown (40d), so distinct
  drawdowns each register instead of merging.
- Fair comparison: each indicator now warns at its OWN 80th percentile instead of
  a shared absolute 60, removing the artifact that muted the coincident baseline.
- Per-event breakdown (date · depth · breadth lead · coincident lead) so a median
  over a tiny sample can't hide an apples-to-oranges comparison — you see whether
  both warned on the same drawdown.
- Surface precision/recall (best row) + base rate per indicator — the honest edge
  read, not just lead time.

Re-run the Event Study job to regenerate the cached report in the new shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 14:54:29 +02:00
parent f8d62e4074
commit 7c5fb1138d
4 changed files with 173 additions and 35 deletions
+25
View File
@@ -6,6 +6,8 @@ from datetime import date, timedelta
from app.services.breadth_service import _breadth_from_closes, compute_divergence_series
from app.services.event_study_service import (
_lead,
_percentile,
detect_events,
event_centered,
signal_centered,
@@ -40,6 +42,29 @@ def test_detect_events_two_after_recovery():
assert len(events) == 2
def test_detect_events_cooldown_suppresses_close_recross():
# Dips below threshold then re-crosses only a few bars later.
closes = [100.0] * 300 + [85.0] * 3 + [100.0] * 3 + [85.0] * 3
dates = _days(len(closes))
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=40)) == 1
assert len(detect_events(closes, dates, threshold_pct=15.0, cooldown=3)) == 2
def test_percentile_interpolation():
vals = [float(v) for v in range(0, 101, 10)] # 0,10,...,100
assert _percentile(vals, 50) == 50.0
assert _percentile(vals, 80) == 80.0
assert _percentile([], 50) is None
def test_lead_earliest_crossing():
dates = _days(200)
t0 = 120
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
assert _lead(indicator, t0, dates, pre=60, threshold=60.0) == 30
assert _lead(indicator, t0, dates, pre=60, threshold=80.0) is None
# ---------------------------------------------------------------------------
# Event-centered lead time
# ---------------------------------------------------------------------------