"""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