You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

389 lines
13 KiB
Python

"""Unit tests for libs.backtest.scoring."""
from __future__ import annotations
import pytest
from libs.backtest.scoring import (
_close_strength_score,
_direction_clarity_score,
_earnings_surprise_score,
_event_quality_score,
_gap_score,
_parse_confidence_score,
_reaction_score,
_risk_penalty_score,
_text_sentiment_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.02)
def test_ideal_setup_scores_high(self):
"""Moderate positive return + close near high + healthy volume.
event=0.5*0.35 + reaction=0.9*0.25 + close=0.82*0.18 + volume=0.8*0.14 + gap=0.8*0.08 ≈ 0.72
"""
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.65
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.38
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.65, f"AAPL should be above 0.65, got {aapl:.3f}"
assert tsla < 0.46, f"TSLA should be below 0.46, got {tsla:.3f}"
assert aapl > tsla
def test_event_features_boost_score(self):
"""Strong event features should boost overall score."""
market_only = compute_entry_score({
"reaction_day_return": 0.01,
"close_location": 0.6,
"volume_ratio_20d": 1.5,
"gap_size": 0.01,
})
with_events = compute_entry_score({
"reaction_day_return": 0.01,
"close_location": 0.6,
"volume_ratio_20d": 1.5,
"gap_size": 0.01,
"signal_strength_score": 0.8,
"guidance_direction_score": 1.0,
"document_quality_score": 0.7,
"oneoff_penalty": 0.0,
})
assert with_events > market_only
def test_removed_features_dont_affect_composite(self):
"""Changing non-alpha features should not change composite score.
eps_growth_qoq, oneoff_penalty, parse_confidence_overall,
event_direction, and lm_net_sentiment are excluded from composite.
"""
base_row = {
"reaction_day_return": 0.01,
"close_location": 0.6,
"volume_ratio_20d": 1.5,
"gap_size": 0.01,
}
base_score = compute_entry_score(base_row)
# Varying removed features should not change score
for extra in [
{"eps_growth_qoq": 0.50},
{"oneoff_penalty": 1.0},
{"parse_confidence_overall": 0.9},
{"event_direction": "bullish"},
{"lm_net_sentiment": 0.01},
]:
row = {**base_row, **extra}
assert compute_entry_score(row) == pytest.approx(base_score), (
f"Feature {list(extra.keys())[0]} should not affect composite"
)
class TestEarningsSurpriseScore:
"""Earnings surprise (SUE) scoring."""
def test_strong_beat(self):
assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": 0.30}) == 0.9
def test_moderate_beat(self):
assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": 0.10}) == 0.75
def test_inline(self):
assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": 0.0}) == 0.5
def test_moderate_miss(self):
assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": -0.10}) == 0.25
def test_severe_miss(self):
assert _earnings_surprise_score({"event_type": "earnings_release", "eps_growth_qoq": -0.30}) == 0.1
def test_non_earnings_returns_neutral(self):
assert _earnings_surprise_score({"event_type": "guidance_update", "eps_growth_qoq": 0.30}) == 0.5
def test_missing_returns_neutral(self):
assert _earnings_surprise_score({"event_type": "earnings_release"}) == 0.5
def test_no_event_type_returns_neutral(self):
assert _earnings_surprise_score({}) == 0.5
class TestEventQualityScore:
"""Event quality scoring."""
def test_strong_signals(self):
score = _event_quality_score({
"signal_strength_score": 0.8,
"guidance_direction_score": 1.0,
"document_quality_score": 0.7,
})
assert score > 0.7
def test_weak_signals(self):
score = _event_quality_score({
"signal_strength_score": 0.0,
"guidance_direction_score": 0.0,
"document_quality_score": 0.3,
})
assert score < 0.15
def test_missing_returns_neutral(self):
assert _event_quality_score({}) == 0.5
def test_partial_features(self):
"""Works with only some event features present."""
score = _event_quality_score({"signal_strength_score": 0.8})
assert score == pytest.approx(0.8, abs=0.01)
class TestRiskPenaltyScore:
"""Risk penalty scoring."""
def test_no_risk(self):
assert _risk_penalty_score({"oneoff_penalty": 0.0}) == pytest.approx(0.9)
def test_max_risk(self):
assert _risk_penalty_score({"oneoff_penalty": 1.0}) == pytest.approx(0.2)
def test_moderate_risk(self):
score = _risk_penalty_score({"oneoff_penalty": 0.5})
assert 0.4 < score < 0.7
def test_missing_returns_neutral(self):
assert _risk_penalty_score({}) == 0.5
class TestParseConfidenceScore:
"""Parse confidence scoring."""
def test_high_confidence(self):
assert _parse_confidence_score({"parse_confidence_overall": 0.9}) == 0.9
def test_moderate_confidence(self):
assert _parse_confidence_score({"parse_confidence_overall": 0.7}) == 0.7
def test_borderline_confidence(self):
assert _parse_confidence_score({"parse_confidence_overall": 0.55}) == 0.5
def test_low_confidence(self):
assert _parse_confidence_score({"parse_confidence_overall": 0.45}) == 0.3
def test_very_low_confidence(self):
assert _parse_confidence_score({"parse_confidence_overall": 0.3}) == 0.2
def test_missing_returns_neutral(self):
assert _parse_confidence_score({}) == 0.5
class TestDirectionClarityScore:
"""Direction clarity scoring."""
def test_bullish(self):
assert _direction_clarity_score({"event_direction": "bullish"}) == 0.9
def test_mixed(self):
assert _direction_clarity_score({"event_direction": "mixed"}) == 0.4
def test_neutral(self):
assert _direction_clarity_score({"event_direction": "neutral"}) == 0.3
def test_bearish(self):
assert _direction_clarity_score({"event_direction": "bearish"}) == 0.1
def test_unknown(self):
assert _direction_clarity_score({"event_direction": "unknown"}) == 0.2
def test_missing_returns_neutral(self):
assert _direction_clarity_score({}) == 0.5