7c5fb1138d
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>
130 lines
5.1 KiB
Python
130 lines
5.1 KiB
Python
"""Unit tests for the breadth indicator and the event-study measurement."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
def _days(n: int, start: date = date(2021, 1, 1)) -> list[date]:
|
|
return [start + timedelta(days=i) for i in range(n)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_detect_events_single_drawdown():
|
|
closes = [100.0] * 300 + [85.0] * 5 # 15% off the trailing high -> one event
|
|
dates = _days(len(closes))
|
|
events = detect_events(closes, dates, threshold_pct=15.0)
|
|
assert len(events) == 1
|
|
assert events[0]["index"] == 300
|
|
|
|
|
|
def test_detect_events_dedup_without_recovery():
|
|
closes = [100.0] * 300 + [85.0] * 5 + [80.0] * 5 # deepens but never recovers
|
|
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0)
|
|
assert len(events) == 1
|
|
|
|
|
|
def test_detect_events_two_after_recovery():
|
|
closes = [100.0] * 300 + [85.0] * 10 + [100.0] * 300 + [85.0] * 10
|
|
events = detect_events(closes, _days(len(closes)), threshold_pct=15.0)
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_event_centered_lead_time():
|
|
dates = _days(200)
|
|
t0 = 120
|
|
# Indicator goes hot 30 days before t0 and stays hot through t0.
|
|
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
|
|
res = event_centered(indicator, [t0], dates, pre=60, post=20, threshold=60.0)
|
|
assert res["median_lead_days"] == 30
|
|
assert res["events_with_signal"] == 1
|
|
|
|
|
|
def test_breadth_divergence_leads_coincident():
|
|
dates = _days(200)
|
|
t0 = 120
|
|
breadth_ind = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
|
|
coincident = {dates[i]: (70.0 if t0 - 2 <= i <= t0 else 10.0) for i in range(len(dates))}
|
|
bd = event_centered(breadth_ind, [t0], dates, threshold=60.0)
|
|
cd = event_centered(coincident, [t0], dates, threshold=60.0)
|
|
assert bd["median_lead_days"] > cd["median_lead_days"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signal-centered precision / recall
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_signal_centered_base_rate_and_recall():
|
|
dates = _days(200)
|
|
t0 = 120
|
|
indicator = {dates[i]: (70.0 if t0 - 30 <= i <= t0 else 10.0) for i in range(len(dates))}
|
|
res = signal_centered(indicator, [t0], dates, horizon=20)
|
|
assert 0.0 < res["base_rate"] < 1.0
|
|
# An aligned indicator should catch some of the pre-event window at a mid threshold.
|
|
row60 = next(r for r in res["rows"] if r["threshold"] == 60)
|
|
assert row60["recall"] is not None and row60["recall"] > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Breadth aggregation + divergence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_breadth_from_closes_fraction_above_sma():
|
|
dates = _days(5)
|
|
closes_by_symbol = {
|
|
"A": list(zip(dates, [1.0, 2.0, 3.0, 4.0, 5.0])), # rising -> above its SMA
|
|
"B": list(zip(dates, [5.0, 4.0, 3.0, 2.0, 1.0])), # falling -> below
|
|
"C": list(zip(dates, [3.0, 3.0, 3.0, 3.0, 3.0])), # flat -> not strictly above
|
|
}
|
|
breadth = _breadth_from_closes(closes_by_symbol, window=3, min_tickers=2)
|
|
# At d2: SMA(3) over each -> only A is strictly above -> 1/3.
|
|
assert breadth[dates[2]] == round(1 / 3 * 100, 2)
|
|
|
|
|
|
def test_divergence_high_when_price_up_breadth_down():
|
|
dates = _days(10)
|
|
breadth = {dates[i]: 80.0 - i * 3 for i in range(len(dates))} # falling breadth
|
|
benchmark = list(zip(dates, [100.0 + i for i in range(len(dates))])) # rising price
|
|
div = compute_divergence_series(breadth, benchmark, lookback=3)
|
|
last = div[dates[-1]]
|
|
assert last > 50.0 # fragile: price up while breadth deteriorates
|