"""Unit tests for libs.backtest.scoring.""" from __future__ import annotations import pytest from libs.backtest.scoring import ( _close_strength_score, _gap_score, _reaction_score, _volume_score, compute_entry_score, ) class TestReactionScore: """Reaction day return scoring.""" def test_sweet_spot_moderate_positive(self): """Moderate positive return (0.5-3%) is ideal.""" assert _reaction_score({"reaction_day_return": 0.01}) == 0.9 assert _reaction_score({"reaction_day_return": 0.025}) == 0.9 def test_flat(self): """Flat to slightly positive (0-0.5%).""" assert _reaction_score({"reaction_day_return": 0.002}) == 0.65 def test_large_positive_penalized(self): """Large positive (3-8%) — getting priced in.""" assert _reaction_score({"reaction_day_return": 0.05}) == 0.45 def test_extreme_positive_penalized(self): """Extreme positive (>8%) — mean reversion risk.""" assert _reaction_score({"reaction_day_return": 0.15}) == 0.2 def test_mild_negative(self): """Small negative (-2% to 0%).""" assert _reaction_score({"reaction_day_return": -0.01}) == 0.4 def test_moderate_negative(self): """Moderate negative (-5% to -2%).""" assert _reaction_score({"reaction_day_return": -0.03}) == 0.25 def test_strongly_bearish(self): """Strongly bearish (<-5%).""" assert _reaction_score({"reaction_day_return": -0.08}) == 0.1 def test_missing_returns_default(self): assert _reaction_score({}) == 0.5 class TestCloseStrengthScore: """Close location scoring.""" def test_near_high(self): score = _close_strength_score({"close_location": 0.9}) assert score == pytest.approx(0.91, abs=0.01) def test_near_low(self): score = _close_strength_score({"close_location": 0.1}) assert score == pytest.approx(0.19, abs=0.01) def test_midpoint(self): score = _close_strength_score({"close_location": 0.5}) assert score == pytest.approx(0.55, abs=0.01) def test_missing_returns_default(self): assert _close_strength_score({}) == 0.5 def test_clamped_above_1(self): """Values > 1.0 are clamped.""" score = _close_strength_score({"close_location": 1.5}) assert score == pytest.approx(1.0, abs=0.01) class TestVolumeScore: """Volume ratio scoring.""" def test_healthy_conviction(self): assert _volume_score({"volume_ratio_20d": 1.5}) == 0.8 def test_normal(self): assert _volume_score({"volume_ratio_20d": 1.1}) == 0.6 def test_high_possible_exhaustion(self): assert _volume_score({"volume_ratio_20d": 2.5}) == 0.55 def test_extreme_exhaustion(self): assert _volume_score({"volume_ratio_20d": 4.0}) == 0.4 def test_below_average(self): assert _volume_score({"volume_ratio_20d": 0.8}) == 0.3 def test_missing_returns_default(self): assert _volume_score({}) == 0.5 class TestGapScore: """Gap size scoring.""" def test_orderly_positive(self): assert _gap_score({"gap_size": 0.01}) == 0.8 def test_neutral(self): assert _gap_score({"gap_size": 0.002}) == 0.6 def test_extended(self): assert _gap_score({"gap_size": 0.03}) == 0.5 def test_exhaustion_gap(self): assert _gap_score({"gap_size": 0.07}) == 0.3 def test_mild_negative(self): assert _gap_score({"gap_size": -0.01}) == 0.4 def test_bearish_gap(self): assert _gap_score({"gap_size": -0.03}) == 0.2 def test_missing_returns_default(self): assert _gap_score({}) == 0.5 class TestComputeEntryScore: """Composite score tests.""" def test_all_missing_returns_neutral(self): """All features missing → all defaults at 0.5 → composite 0.5.""" score = compute_entry_score({}) assert score == pytest.approx(0.5, abs=0.01) def test_ideal_setup_scores_high(self): """Moderate positive return + close near high + healthy volume.""" row = { "reaction_day_return": 0.01, # sweet spot → 0.9 "close_location": 0.8, # near high → 0.82 "volume_ratio_20d": 1.5, # conviction → 0.8 "gap_size": 0.01, # orderly → 0.8 } score = compute_entry_score(row) assert score > 0.8 def test_bearish_setup_scores_low(self): """Negative return + close near low + below avg volume.""" row = { "reaction_day_return": -0.04, # moderate negative → 0.25 "close_location": 0.1, # near low → 0.19 "volume_ratio_20d": 0.8, # no conviction → 0.3 "gap_size": -0.03, # bearish gap → 0.2 } score = compute_entry_score(row) assert score < 0.3 def test_extreme_positive_penalized(self): """Very large positive reaction should be penalized.""" extreme = compute_entry_score({ "reaction_day_return": 0.15, # extreme → 0.2 "close_location": 0.7, "volume_ratio_20d": 3.5, # extreme volume → 0.4 "gap_size": 0.08, # exhaustion gap → 0.3 }) moderate = compute_entry_score({ "reaction_day_return": 0.015, # sweet spot → 0.9 "close_location": 0.7, "volume_ratio_20d": 1.5, # healthy → 0.8 "gap_size": 0.01, # orderly → 0.8 }) assert moderate > extreme def test_score_bounded_0_to_1(self): """Score is always in [0, 1].""" extremes = [ {"reaction_day_return": -0.5, "close_location": 0.0, "volume_ratio_20d": 0.1, "gap_size": -0.1}, {"reaction_day_return": 0.5, "close_location": 1.0, "volume_ratio_20d": 10.0, "gap_size": 0.2}, ] for row in extremes: score = compute_entry_score(row) assert 0.0 <= score <= 1.0 def test_aapl_like_scores_highest(self): """AAPL-like setup (moderate +, close near high) scores well.""" aapl = compute_entry_score({ "reaction_day_return": 0.007, "close_location": 0.74, "volume_ratio_20d": 1.45, "gap_size": 0.006, }) # DDOG-like: extreme positive, high volume ddog = compute_entry_score({ "reaction_day_return": 0.137, "close_location": 0.63, "volume_ratio_20d": 2.83, "gap_size": 0.089, }) assert aapl > ddog, f"AAPL-like {aapl:.3f} should beat DDOG-like {ddog:.3f}" def test_real_data_scores(self): """Verify scores for real data samples match expectations.""" # TSLA 1/2: bearish (return -2.6%, close near low) tsla = compute_entry_score({ "reaction_day_return": -0.026, "close_location": 0.12, "volume_ratio_20d": 1.13, "gap_size": 0.018, }) # AAPL 1/29: bullish (moderate +, close near high) aapl = compute_entry_score({ "reaction_day_return": 0.007, "close_location": 0.74, "volume_ratio_20d": 1.45, "gap_size": 0.006, }) assert aapl > 0.6, f"AAPL should be above 0.6, got {aapl:.3f}" assert tsla < 0.4, f"TSLA should be below 0.4, got {tsla:.3f}" assert aapl > tsla