"""Unit tests for libs/backtest/selector.py.""" from __future__ import annotations import datetime as dt from zoneinfo import ZoneInfo import pytest from libs.backtest.domain import ( EventTypeProfile, SignalConfig, StrategyEngineConfig, UniverseConfig, ) _UTC = ZoneInfo("UTC") def _make_raw_row(**kwargs) -> dict: defaults = { "event_id": "EVT::TEST::001", "symbol": "AAPL", "issuer_id": "ISSUER::0000320193", "score": 0.75, "sector": "Technology", "event_type": "earnings", "event_timestamp": "2026-01-05T21:00:00+00:00", "filing_time_bucket": "post_market", "reaction_date": "2026-01-06", "entry_date": "2026-01-07", "entry_price": 150.0, "avg_dollar_volume": 5_000_000.0, "atr_14": 3.5, } defaults.update(kwargs) return defaults class TestBuildCandidate: def test_basic(self): from libs.backtest.selector import build_candidate row = _make_raw_row() c = build_candidate(row) assert c is not None assert c.symbol == "AAPL" assert c.score == 0.75 assert c.execution_date == dt.date(2026, 1, 7) assert c.event_timestamp.tzinfo is not None assert c.timing_class == "after_close" def test_null_timestamp_returns_none(self): from libs.backtest.selector import build_candidate row = _make_raw_row(event_timestamp=None) assert build_candidate(row) is None def test_zero_entry_price_returns_none(self): from libs.backtest.selector import build_candidate row = _make_raw_row(entry_price=0.0) assert build_candidate(row) is None def test_missing_entry_price_returns_none(self): from libs.backtest.selector import build_candidate row = _make_raw_row() del row["entry_price"] assert build_candidate(row) is None def test_null_exec_date_returns_none(self): from libs.backtest.selector import build_candidate row = _make_raw_row() del row["entry_date"] assert build_candidate(row) is None def test_sector_defaults_to_unknown(self): from libs.backtest.selector import build_candidate row = _make_raw_row(sector=None) c = build_candidate(row) assert c is not None assert c.sector == "UNKNOWN" def test_score_bucket_classification(self): from libs.backtest.selector import build_candidate c = build_candidate(_make_raw_row(score=0.85)) assert c.score_bucket == "high" c = build_candidate(_make_raw_row(score=0.65)) assert c.score_bucket == "medium_high" c = build_candidate(_make_raw_row(score=0.45)) assert c.score_bucket == "medium" c = build_candidate(_make_raw_row(score=0.25)) assert c.score_bucket == "medium_low" c = build_candidate(_make_raw_row(score=0.10)) assert c.score_bucket == "low" def test_same_day_timing_and_direction_from_reaction(self): from libs.backtest.selector import build_candidate row = _make_raw_row( event_date="2026-01-06", reaction_date="2026-01-06", execution_date="2026-01-07", trade_direction="", reaction_day_return=-0.12, ) c = build_candidate(row) assert c is not None assert c.event_date == dt.date(2026, 1, 6) assert c.timing_class == "same_day" assert c.trade_direction == "short" def test_reaction_close_engine_uses_event_close(self): from libs.backtest.selector import build_candidate engine = StrategyEngineConfig( engine_id="earnings_same_day_long_close_v1", event_types=["earnings"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=3, engine_risk_budget_pct=0.35, ) row = _make_raw_row( event_date="2026-01-06", reaction_date="2026-01-06", event_close=149.5, entry_date="2026-01-07", reaction_day_return=0.11, ) c = build_candidate(row, strategy_engine=engine) assert c is not None assert c.execution_date == dt.date(2026, 1, 6) assert c.entry_price_est == pytest.approx(149.5) assert c.engine_id == "earnings_same_day_long_close_v1" assert c.entry_timing_policy == "reaction_close" def test_engine_execution_overrides_are_copied_to_candidate(self): from libs.backtest.selector import build_candidate engine = StrategyEngineConfig( engine_id="earnings_same_day_long_trend_v1", event_types=["earnings"], timing_class="same_day", direction="long_only", entry_timing_policy="reaction_close", max_holding_days=12, engine_risk_budget_pct=0.25, target_atr_multiplier_override=2.5, target_1_fraction_override=0.33, trailing_model_override="pct_10", trailing_warmup_days_override=2, ) row = _make_raw_row( event_date="2026-01-06", reaction_date="2026-01-06", event_close=149.5, entry_date="2026-01-07", reaction_day_return=0.11, ) c = build_candidate(row, strategy_engine=engine) assert c is not None assert c.engine_max_holding_days == 12 assert c.engine_risk_budget_pct == pytest.approx(0.25) assert c.engine_target_atr_multiplier == pytest.approx(2.5) assert c.engine_target_1_fraction == pytest.approx(0.33) assert c.engine_trailing_model == "pct_10" assert c.engine_trailing_warmup_days == 2 def test_engine_route_skips_non_matching_direction(self): from libs.backtest.selector import build_candidate engine = StrategyEngineConfig( engine_id="short_only_engine", event_types=["earnings"], timing_class="after_close", direction="short_only", ) row = _make_raw_row(reaction_day_return=0.09, trade_direction="long") assert build_candidate(row, strategy_engine=engine) is None def test_select_candidates_recomputes_pead_score_for_engine_thresholds(self): from libs.backtest.selector import select_candidates rows = [ _make_raw_row( symbol="LOWVOL", score=0.8, event_type="earnings_release", reaction_day_return=0.14, volume_ratio_20d=2.4, gap_size=0.01, ), _make_raw_row( symbol="HIGHVOL", score=0.8, event_type="earnings_release", reaction_day_return=0.14, volume_ratio_20d=4.5, gap_size=0.01, ), ] universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) signal = SignalConfig( scoring_model="pead", score_threshold=0.65, pead_reaction_threshold=0.10, pead_volume_threshold=2.0, max_candidates_per_day=5, ) engine = StrategyEngineConfig( engine_id="after_close_long_quality", event_types=["earnings_release"], timing_class="after_close", direction="long_only", pead_volume_threshold_override=3.0, ) selected = select_candidates(rows, universe, signal, strategy_engine=engine) assert [candidate.symbol for candidate in selected] == ["HIGHVOL"] def test_select_candidates_uses_engine_score_threshold_override(self): from libs.backtest.selector import select_candidates rows = [ _make_raw_row( symbol="PASS", score=0.78, event_type="earnings_release", reaction_day_return=0.16, volume_ratio_20d=4.0, gap_size=0.01, ), _make_raw_row( symbol="FAIL", score=0.72, event_type="earnings_release", reaction_day_return=0.14, volume_ratio_20d=4.0, gap_size=0.01, ), ] universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) signal = SignalConfig( scoring_model="pead", score_threshold=0.65, pead_reaction_threshold=0.10, pead_volume_threshold=2.0, max_candidates_per_day=5, ) engine = StrategyEngineConfig( engine_id="after_close_long_quality", event_types=["earnings_release"], timing_class="after_close", direction="long_only", score_threshold_override=0.75, ) selected = select_candidates(rows, universe, signal, strategy_engine=engine) assert [candidate.symbol for candidate in selected] == ["PASS"] def test_select_candidates_respects_engine_reaction_day_return_bounds(self): from libs.backtest.selector import select_candidates rows = [ _make_raw_row( symbol="CRASH", score=0.85, event_type="earnings_release", event_date="2026-01-06", reaction_date="2026-01-06", reaction_day_return=-0.52, volume_ratio_20d=8.0, trade_direction="short", ), _make_raw_row( symbol="NORMAL", score=0.82, event_type="earnings_release", event_date="2026-01-06", reaction_date="2026-01-06", reaction_day_return=-0.18, volume_ratio_20d=5.0, trade_direction="short", ), ] universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) signal = SignalConfig( scoring_model="pead", score_threshold=0.65, pead_reaction_threshold=0.10, pead_volume_threshold=2.0, max_candidates_per_day=5, ) engine = StrategyEngineConfig( engine_id="same_day_short_filtered", event_types=["earnings_release"], timing_class="same_day", direction="short_only", reaction_day_return_min=-0.45, ) selected = select_candidates(rows, universe, signal, strategy_engine=engine) assert [candidate.symbol for candidate in selected] == ["NORMAL"] def test_select_candidates_respects_engine_reaction_day_return_upper_bound(self): from libs.backtest.selector import select_candidates rows = [ _make_raw_row( symbol="TOO_HOT", score=0.90, event_type="earnings_release", event_date="2026-01-06", reaction_date="2026-01-06", reaction_day_return=0.42, volume_ratio_20d=6.0, trade_direction="long", ), _make_raw_row( symbol="OK", score=0.80, event_type="earnings_release", event_date="2026-01-06", reaction_date="2026-01-06", reaction_day_return=0.18, volume_ratio_20d=4.0, trade_direction="long", ), ] universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) signal = SignalConfig( scoring_model="pead", score_threshold=0.65, pead_reaction_threshold=0.10, pead_volume_threshold=2.0, max_candidates_per_day=5, ) engine = StrategyEngineConfig( engine_id="same_day_long_capped", event_types=["earnings_release"], timing_class="same_day", direction="long_only", reaction_day_return_max=0.30, ) selected = select_candidates(rows, universe, signal, strategy_engine=engine) assert [candidate.symbol for candidate in selected] == ["OK"] def test_select_candidates_respects_engine_gap_size_bounds(self): from libs.backtest.selector import select_candidates rows = [ _make_raw_row( symbol="TIGHT", score=0.82, event_type="earnings_release", event_date="2026-01-06", reaction_date="2026-01-06", reaction_day_return=0.18, volume_ratio_20d=4.0, gap_size=0.04, trade_direction="long", ), _make_raw_row( symbol="WIDE", score=0.84, event_type="earnings_release", event_date="2026-01-06", reaction_date="2026-01-06", reaction_day_return=0.18, volume_ratio_20d=4.0, gap_size=0.16, trade_direction="long", ), _make_raw_row( symbol="TOO_WIDE", score=0.86, event_type="earnings_release", event_date="2026-01-06", reaction_date="2026-01-06", reaction_day_return=0.18, volume_ratio_20d=4.0, gap_size=0.34, trade_direction="long", ), ] universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0) signal = SignalConfig( scoring_model="pead", score_threshold=0.65, pead_reaction_threshold=0.10, pead_volume_threshold=2.0, max_candidates_per_day=5, ) engine = StrategyEngineConfig( engine_id="same_day_long_gapped", event_types=["earnings_release"], timing_class="same_day", direction="long_only", gap_size_min=0.10, gap_size_max=0.30, ) selected = select_candidates(rows, universe, signal, strategy_engine=engine) assert [candidate.symbol for candidate in selected] == ["WIDE"] class TestRankCandidates: def test_sorted_by_score_desc(self): from libs.backtest.selector import build_candidate, rank_candidates rows = [ _make_raw_row(symbol="A", score=0.5, avg_dollar_volume=1e6), _make_raw_row(symbol="B", score=0.8, avg_dollar_volume=1e6), _make_raw_row(symbol="C", score=0.6, avg_dollar_volume=1e6), ] candidates = [build_candidate(r) for r in rows] ranked = rank_candidates([c for c in candidates if c]) assert ranked[0].symbol == "B" assert ranked[1].symbol == "C" assert ranked[2].symbol == "A" def test_tiebreak_by_avg_dollar_volume(self): from libs.backtest.selector import build_candidate, rank_candidates rows = [ _make_raw_row(symbol="A", score=0.7, avg_dollar_volume=1e6), _make_raw_row(symbol="B", score=0.7, avg_dollar_volume=5e6), ] candidates = [build_candidate(r) for r in rows] ranked = rank_candidates([c for c in candidates if c]) assert ranked[0].symbol == "B" # higher avg_dollar_volume def test_tiebreak_by_symbol_asc(self): from libs.backtest.selector import build_candidate, rank_candidates rows = [ _make_raw_row(symbol="Z", score=0.7, avg_dollar_volume=1e6), _make_raw_row(symbol="A", score=0.7, avg_dollar_volume=1e6), ] candidates = [build_candidate(r) for r in rows] ranked = rank_candidates([c for c in candidates if c]) assert ranked[0].symbol == "A" def test_deterministic(self): from libs.backtest.selector import build_candidate, rank_candidates rows = [ _make_raw_row(symbol="C", score=0.9), _make_raw_row(symbol="A", score=0.7), _make_raw_row(symbol="B", score=0.8), ] candidates = [build_candidate(r) for r in rows] r1 = rank_candidates([c for c in candidates if c]) r2 = rank_candidates([c for c in candidates if c]) assert [c.symbol for c in r1] == [c.symbol for c in r2] class TestFilterCandidates: def test_score_threshold(self): from libs.backtest.selector import build_candidate, filter_by_score rows = [ _make_raw_row(symbol="A", score=0.3), _make_raw_row(symbol="B", score=0.7), _make_raw_row(symbol="C", score=0.5), ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] filtered = filter_by_score(candidates, score_threshold=0.5) assert len(filtered) == 2 assert all(c.score >= 0.5 for c in filtered) def test_min_price_filter(self): from libs.backtest.selector import build_candidate, filter_by_universe u = UniverseConfig(min_price=100.0, min_avg_dollar_volume=0) rows = [ _make_raw_row(symbol="CHEAP", entry_price=50.0), _make_raw_row(symbol="OK", entry_price=150.0), ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] filtered = filter_by_universe(candidates, u) assert len(filtered) == 1 assert filtered[0].symbol == "OK" def test_min_adv_filter(self): from libs.backtest.selector import build_candidate, filter_by_universe u = UniverseConfig(min_price=0, min_avg_dollar_volume=2_000_000) rows = [ _make_raw_row(symbol="ILLIQUID", avg_dollar_volume=500_000), _make_raw_row(symbol="LIQUID", avg_dollar_volume=5_000_000), ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] filtered = filter_by_universe(candidates, u) assert len(filtered) == 1 assert filtered[0].symbol == "LIQUID" def test_truncate(self): from libs.backtest.selector import build_candidate, rank_candidates, truncate_candidates rows = [_make_raw_row(symbol=s, score=0.9 - i * 0.1) for i, s in enumerate("ABCDE")] candidates = rank_candidates([build_candidate(r) for r in rows if build_candidate(r)]) truncated = truncate_candidates(candidates, max_per_day=3) assert len(truncated) == 3 class TestFilterByEventType: def test_disabled_event_type_filtered(self): from libs.backtest.selector import build_candidate, filter_by_event_type rows = [ _make_raw_row(symbol="A", event_type="earnings_release"), _make_raw_row(symbol="B", event_type="management_change"), ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] profiles = { "earnings_release": EventTypeProfile(enabled=True), "management_change": EventTypeProfile(enabled=False), } filtered = filter_by_event_type(candidates, profiles) assert len(filtered) == 1 assert filtered[0].symbol == "A" def test_per_type_score_threshold(self): from libs.backtest.selector import build_candidate, filter_by_event_type rows = [ _make_raw_row(symbol="A", event_type="earnings_release", score=0.55), _make_raw_row(symbol="B", event_type="earnings_release", score=0.75), ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] profiles = { "earnings_release": EventTypeProfile(score_threshold_override=0.6), } filtered = filter_by_event_type(candidates, profiles) assert len(filtered) == 1 assert filtered[0].symbol == "B" def test_unknown_event_type_blocked(self): """Event types not in profiles dict are blocked (default deny).""" from libs.backtest.selector import build_candidate, filter_by_event_type rows = [ _make_raw_row(symbol="A", event_type="earnings_release"), _make_raw_row(symbol="B", event_type="unknown_type"), ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] profiles = { "earnings_release": EventTypeProfile(enabled=True), } filtered = filter_by_event_type(candidates, profiles) assert len(filtered) == 1 assert filtered[0].symbol == "A" def test_unknown_event_type_passes_when_in_profiles(self): """Event type 'unknown' passes through when explicitly enabled in profiles.""" from libs.backtest.selector import build_candidate, filter_by_event_type rows = [ _make_raw_row(symbol="A", event_type="earnings_release"), _make_raw_row(symbol="B", event_type="unknown"), ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] profiles = { "earnings_release": EventTypeProfile(enabled=True), "unknown": EventTypeProfile(enabled=True, direction_filter="any"), } filtered = filter_by_event_type(candidates, profiles) assert len(filtered) == 2 symbols = [c.symbol for c in filtered] assert "A" in symbols assert "B" in symbols def test_no_profiles_passthrough(self): from libs.backtest.selector import build_candidate, filter_by_event_type rows = [_make_raw_row(symbol="A")] candidates = [build_candidate(r) for r in rows if build_candidate(r)] assert filter_by_event_type(candidates, {}) == candidates class TestSelectCandidates: def test_full_pipeline(self): from libs.backtest.selector import select_candidates rows = [ _make_raw_row(symbol="A", score=0.9, avg_dollar_volume=5e6, entry_price=100.0), _make_raw_row(symbol="B", score=0.3, avg_dollar_volume=5e6, entry_price=100.0), # below threshold _make_raw_row(symbol="C", score=0.8, avg_dollar_volume=1e4, entry_price=100.0), # low ADV _make_raw_row(symbol="D", score=0.7, avg_dollar_volume=5e6, entry_price=2.0), # below min_price ] u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000) s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10) result = select_candidates(rows, u, s) symbols = [c.symbol for c in result] assert "A" in symbols assert "B" not in symbols # below threshold assert "C" not in symbols # low ADV assert "D" not in symbols # below min_price def test_pipeline_with_event_type_profiles(self): from libs.backtest.selector import select_candidates rows = [ _make_raw_row(symbol="A", score=0.9, avg_dollar_volume=5e6, entry_price=100.0, event_type="earnings_release"), _make_raw_row(symbol="B", score=0.7, avg_dollar_volume=5e6, entry_price=100.0, event_type="management_change"), ] u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000) s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10) profiles = { "earnings_release": EventTypeProfile(enabled=True), "management_change": EventTypeProfile(enabled=False), } result = select_candidates(rows, u, s, event_type_profiles=profiles) assert len(result) == 1 assert result[0].symbol == "A"