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.
895 lines
32 KiB
Python
895 lines
32 KiB
Python
"""Unit tests for libs.backtest.scoring."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from libs.backtest.scoring import (
|
|
_close_strength_score,
|
|
_compute_management_change_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,
|
|
compute_microstructure_score,
|
|
compute_patient_drift_score,
|
|
compute_return_max_long_score,
|
|
compute_return_max_long_score_v2,
|
|
compute_return_max_long_score_v3,
|
|
compute_return_max_long_score_v4,
|
|
compute_return_max_long_score_v5,
|
|
compute_return_max_long_score_v11,
|
|
compute_return_max_long_score_v11g,
|
|
)
|
|
|
|
|
|
class TestReactionScore:
|
|
"""Reaction day return scoring — simple directional mapping."""
|
|
|
|
def test_strong_positive(self):
|
|
"""Strong positive return (>3%)."""
|
|
assert _reaction_score({"reaction_day_return": 0.05}) == 0.7
|
|
assert _reaction_score({"reaction_day_return": 0.15}) == 0.7
|
|
|
|
def test_positive(self):
|
|
"""Positive return (0-3%)."""
|
|
assert _reaction_score({"reaction_day_return": 0.01}) == 0.6
|
|
assert _reaction_score({"reaction_day_return": 0.025}) == 0.6
|
|
assert _reaction_score({"reaction_day_return": 0.002}) == 0.6
|
|
|
|
def test_flat_to_mild_negative(self):
|
|
"""Flat to mild negative (0% to -2%)."""
|
|
assert _reaction_score({"reaction_day_return": -0.01}) == 0.45
|
|
|
|
def test_moderate_negative(self):
|
|
"""Moderate negative (-5% to -2%)."""
|
|
assert _reaction_score({"reaction_day_return": -0.03}) == 0.3
|
|
|
|
def test_strongly_bearish(self):
|
|
"""Strongly bearish (<-5%)."""
|
|
assert _reaction_score({"reaction_day_return": -0.08}) == 0.2
|
|
|
|
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):
|
|
"""Positive return + healthy volume (no event features → event=0.5).
|
|
|
|
event=0.5*0.65 + reaction=0.6*0.20 + volume=0.8*0.15 ≈ 0.565
|
|
"""
|
|
row = {
|
|
"reaction_day_return": 0.01, # positive → 0.6
|
|
"volume_ratio_20d": 1.5, # conviction → 0.8
|
|
}
|
|
score = compute_entry_score(row)
|
|
assert score > 0.55
|
|
|
|
def test_bearish_setup_scores_low(self):
|
|
"""Negative return + below avg volume (no event features → event=0.5).
|
|
|
|
event=0.5*0.65 + reaction=0.3*0.20 + volume=0.3*0.15 ≈ 0.43
|
|
"""
|
|
row = {
|
|
"reaction_day_return": -0.04, # moderate negative → 0.3
|
|
"volume_ratio_20d": 0.8, # no conviction → 0.3
|
|
}
|
|
score = compute_entry_score(row)
|
|
assert score < 0.45
|
|
|
|
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.
|
|
|
|
With no event features, both get event=0.5.
|
|
TSLA: 0.5*0.65 + 0.3*0.20 + 0.6*0.15 = 0.475
|
|
AAPL: 0.5*0.65 + 0.6*0.20 + 0.8*0.15 = 0.565
|
|
"""
|
|
# TSLA 1/2: bearish (return -2.6%)
|
|
tsla = compute_entry_score({
|
|
"reaction_day_return": -0.026,
|
|
"volume_ratio_20d": 1.13,
|
|
})
|
|
# AAPL 1/29: bullish (moderate +)
|
|
aapl = compute_entry_score({
|
|
"reaction_day_return": 0.007,
|
|
"volume_ratio_20d": 1.45,
|
|
})
|
|
assert aapl > 0.55, f"AAPL should be above 0.55, got {aapl:.3f}"
|
|
assert tsla < 0.49, f"TSLA should be below 0.49, 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 TestComputeReturnMaxLongScore:
|
|
def test_bullish_earnings_setup_scores_above_threshold(self):
|
|
score = compute_return_max_long_score(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"parse_confidence_overall": 0.85,
|
|
"parse_confidence_event_direction": 0.80,
|
|
"parse_confidence_guidance": 0.75,
|
|
"guidance_status": "raised",
|
|
"document_quality_score": 0.80,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.10,
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.9,
|
|
"gap_size": 0.015,
|
|
}
|
|
)
|
|
assert score > 0.62
|
|
|
|
def test_non_bullish_direction_is_hard_rejected(self):
|
|
score = compute_return_max_long_score(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "mixed",
|
|
"parse_confidence_overall": 0.90,
|
|
"oneoff_penalty": 0.10,
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.80,
|
|
"volume_ratio_20d": 2.0,
|
|
"gap_size": 0.01,
|
|
}
|
|
)
|
|
assert score == 0.0
|
|
|
|
def test_overheat_penalty_reduces_score(self):
|
|
cool = compute_return_max_long_score(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"parse_confidence_overall": 0.85,
|
|
"parse_confidence_event_direction": 0.80,
|
|
"document_quality_score": 0.80,
|
|
"guidance_direction_score": 0.5,
|
|
"oneoff_penalty": 0.10,
|
|
"reaction_day_return": 0.08,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.9,
|
|
"gap_size": 0.015,
|
|
}
|
|
)
|
|
hot = compute_return_max_long_score(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"parse_confidence_overall": 0.85,
|
|
"parse_confidence_event_direction": 0.80,
|
|
"document_quality_score": 0.80,
|
|
"guidance_direction_score": 0.5,
|
|
"oneoff_penalty": 0.10,
|
|
"reaction_day_return": 0.16,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.9,
|
|
"gap_size": 0.06,
|
|
"attention_wiki_spike_10d": 3.5,
|
|
}
|
|
)
|
|
assert cool > hot
|
|
|
|
def test_missing_direction_confidence_falls_back_to_overall(self):
|
|
score = compute_return_max_long_score(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"parse_confidence_overall": 0.72,
|
|
"document_quality_score": 0.80,
|
|
"guidance_direction_score": 0.80,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.07,
|
|
"close_location": 0.85,
|
|
"volume_ratio_20d": 2.0,
|
|
"gap_size": 0.01,
|
|
}
|
|
)
|
|
assert score > 0.62
|
|
|
|
def test_guidance_confidence_falls_back_to_overall(self):
|
|
score = compute_return_max_long_score(
|
|
{
|
|
"event_type": "guidance_update",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"parse_confidence_overall": 0.78,
|
|
"document_quality_score": 0.75,
|
|
"guidance_direction_score": 1.0,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.06,
|
|
"close_location": 0.80,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
}
|
|
)
|
|
assert score > 0.62
|
|
|
|
def test_v2_allows_mixed_earnings_when_reaction_is_positive(self):
|
|
score = compute_return_max_long_score_v2(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "mixed",
|
|
"parse_confidence_overall": 0.72,
|
|
"document_quality_score": 0.70,
|
|
"signal_strength_score": 0.76,
|
|
"guidance_direction_score": 0.50,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.07,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
}
|
|
)
|
|
assert score > 0.60
|
|
|
|
def test_v2_still_rejects_bearish_earnings(self):
|
|
score = compute_return_max_long_score_v2(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bearish",
|
|
"parse_confidence_overall": 0.72,
|
|
"document_quality_score": 0.70,
|
|
"signal_strength_score": 0.76,
|
|
"guidance_direction_score": 0.50,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.07,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
}
|
|
)
|
|
assert score == 0.0
|
|
|
|
def test_v3_allows_mixed_earnings_when_reaction_is_positive(self):
|
|
score = compute_return_max_long_score_v3(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "mixed",
|
|
"parse_confidence_overall": 0.72,
|
|
"document_quality_score": 0.70,
|
|
"signal_strength_score": 0.76,
|
|
"guidance_direction_score": 0.50,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.07,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
}
|
|
)
|
|
assert score > 0.60
|
|
|
|
def test_v3_still_rejects_bearish_earnings(self):
|
|
score = compute_return_max_long_score_v3(
|
|
{
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bearish",
|
|
"parse_confidence_overall": 0.72,
|
|
"document_quality_score": 0.70,
|
|
"signal_strength_score": 0.76,
|
|
"guidance_direction_score": 0.50,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.07,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
}
|
|
)
|
|
assert score == 0.0
|
|
|
|
def test_v3_weights_market_confirmation_more_than_v2(self):
|
|
row = {
|
|
"event_type": "earnings_release",
|
|
"event_direction": "mixed",
|
|
"parse_confidence_overall": 0.70,
|
|
"document_quality_score": 0.56,
|
|
"signal_strength_score": 0.58,
|
|
"guidance_direction_score": 0.25,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.11,
|
|
"close_location": 0.92,
|
|
"volume_ratio_20d": 2.2,
|
|
"gap_size": 0.01,
|
|
}
|
|
assert compute_return_max_long_score_v3(row) > compute_return_max_long_score_v2(row)
|
|
|
|
|
|
class TestManagementChangeScore:
|
|
"""Management change scoring — bullish reaction + close_location weighted."""
|
|
|
|
def _mc_row(self, **overrides):
|
|
base = {
|
|
"event_type": "management_change",
|
|
"reaction_day_return": 0.015,
|
|
"parse_confidence_overall": 0.55,
|
|
"oneoff_penalty": 0.10,
|
|
"document_quality_score": 0.60,
|
|
"close_location": 0.65,
|
|
"volume_ratio_20d": 1.1,
|
|
"gap_size": 0.005,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_good_mc_scores_above_zero(self):
|
|
score = _compute_management_change_score(self._mc_row())
|
|
assert score > 0.40
|
|
|
|
def test_bearish_reaction_rejected(self):
|
|
assert _compute_management_change_score(self._mc_row(reaction_day_return=-0.02)) == 0.0
|
|
|
|
def test_zero_reaction_rejected(self):
|
|
assert _compute_management_change_score(self._mc_row(reaction_day_return=0.0)) == 0.0
|
|
|
|
def test_low_parse_confidence_rejected(self):
|
|
assert _compute_management_change_score(self._mc_row(parse_confidence_overall=0.40)) == 0.0
|
|
|
|
def test_high_oneoff_penalty_rejected(self):
|
|
assert _compute_management_change_score(self._mc_row(oneoff_penalty=0.45)) == 0.0
|
|
|
|
def test_missing_parse_confidence_rejected(self):
|
|
assert _compute_management_change_score(self._mc_row(parse_confidence_overall=None)) == 0.0
|
|
|
|
def test_higher_close_location_scores_higher(self):
|
|
low = _compute_management_change_score(self._mc_row(close_location=0.40))
|
|
high = _compute_management_change_score(self._mc_row(close_location=0.80))
|
|
assert high > low
|
|
|
|
def test_score_bounded_0_to_1(self):
|
|
extremes = [
|
|
self._mc_row(reaction_day_return=0.001, close_location=0.1, document_quality_score=0.1),
|
|
self._mc_row(reaction_day_return=0.15, close_location=1.0, document_quality_score=1.0),
|
|
]
|
|
for row in extremes:
|
|
score = _compute_management_change_score(row)
|
|
assert 0.0 <= score <= 1.0
|
|
|
|
def test_routed_via_return_max_long_score(self):
|
|
"""management_change events are routed through the return_max_long_v2 scorer."""
|
|
score = compute_return_max_long_score_v2(self._mc_row())
|
|
assert score > 0.0
|
|
|
|
def test_non_mc_event_type_still_zero(self):
|
|
"""Other unsupported event types still return 0."""
|
|
score = compute_return_max_long_score_v2(
|
|
self._mc_row(event_type="other_material_event")
|
|
)
|
|
assert score == 0.0
|
|
|
|
|
|
class TestReturnMaxLongV5MaterialScore:
|
|
"""V5 should keep earnings semantics and add generic material-event scoring."""
|
|
|
|
def _material_row(self, **overrides):
|
|
base = {
|
|
"event_type": "material_contract",
|
|
"event_direction": "unknown",
|
|
"parse_confidence_overall": 0.58,
|
|
"document_quality_score": 0.62,
|
|
"signal_strength_score": 0.66,
|
|
"oneoff_penalty": 0.12,
|
|
"reaction_day_return": 0.06,
|
|
"close_location": 0.78,
|
|
"volume_ratio_20d": 1.12,
|
|
"gap_size": 0.01,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_v5_matches_v2_on_earnings(self):
|
|
row = {
|
|
"event_type": "earnings_release",
|
|
"event_direction": "mixed",
|
|
"parse_confidence_overall": 0.72,
|
|
"document_quality_score": 0.70,
|
|
"signal_strength_score": 0.76,
|
|
"guidance_direction_score": 0.50,
|
|
"oneoff_penalty": 0.05,
|
|
"reaction_day_return": 0.07,
|
|
"close_location": 0.82,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
}
|
|
assert compute_return_max_long_score_v5(row) == pytest.approx(
|
|
compute_return_max_long_score_v2(row)
|
|
)
|
|
|
|
def test_v5_scores_good_material_contract_above_zero(self):
|
|
score = compute_return_max_long_score_v5(self._material_row())
|
|
assert score > 0.55
|
|
|
|
def test_v5_scores_good_other_material_event_above_zero(self):
|
|
score = compute_return_max_long_score_v5(
|
|
self._material_row(
|
|
event_type="other_material_event",
|
|
event_direction="mixed",
|
|
reaction_day_return=0.03,
|
|
close_location=0.68,
|
|
volume_ratio_20d=1.25,
|
|
gap_size=-0.005,
|
|
)
|
|
)
|
|
assert score > 0.45
|
|
|
|
def test_v5_rejects_high_oneoff_material_event(self):
|
|
assert compute_return_max_long_score_v5(
|
|
self._material_row(oneoff_penalty=0.45)
|
|
) == 0.0
|
|
|
|
def test_v4_alias_matches_v5(self):
|
|
row = self._material_row()
|
|
assert compute_return_max_long_score_v4(row) == pytest.approx(
|
|
compute_return_max_long_score_v5(row)
|
|
)
|
|
|
|
|
|
class TestPatientDriftScore:
|
|
"""Patient drift scoring — market-only features, no NLP."""
|
|
|
|
def _pd_row(self, **overrides):
|
|
base = {
|
|
"event_type": "earnings_release",
|
|
"reaction_day_return": 0.06,
|
|
"parse_confidence_overall": 0.70,
|
|
"oneoff_penalty": 0.10,
|
|
"close_location": 0.75,
|
|
"volume_ratio_20d": 1.8,
|
|
"gap_size": 0.01,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_good_setup_scores_above_threshold(self):
|
|
score = compute_patient_drift_score(self._pd_row())
|
|
assert score > 0.35
|
|
|
|
def test_score_bounded_0_to_1(self):
|
|
extremes = [
|
|
self._pd_row(reaction_day_return=0.001, close_location=0.1, volume_ratio_20d=0.5),
|
|
self._pd_row(reaction_day_return=0.20, close_location=1.0, volume_ratio_20d=5.0),
|
|
]
|
|
for row in extremes:
|
|
score = compute_patient_drift_score(row)
|
|
assert 0.0 <= score <= 1.0
|
|
|
|
def test_rejects_non_earnings_guidance(self):
|
|
assert compute_patient_drift_score(self._pd_row(event_type="management_change")) == 0.0
|
|
assert compute_patient_drift_score(self._pd_row(event_type="material_contract")) == 0.0
|
|
|
|
def test_accepts_guidance_update(self):
|
|
score = compute_patient_drift_score(self._pd_row(event_type="guidance_update"))
|
|
assert score > 0.0
|
|
|
|
def test_rejects_negative_reaction(self):
|
|
assert compute_patient_drift_score(self._pd_row(reaction_day_return=-0.02)) == 0.0
|
|
assert compute_patient_drift_score(self._pd_row(reaction_day_return=0.0)) == 0.0
|
|
|
|
def test_rejects_low_parse_confidence(self):
|
|
assert compute_patient_drift_score(self._pd_row(parse_confidence_overall=0.40)) == 0.0
|
|
|
|
def test_rejects_high_oneoff_penalty(self):
|
|
assert compute_patient_drift_score(self._pd_row(oneoff_penalty=0.45)) == 0.0
|
|
|
|
def test_higher_reaction_scores_higher(self):
|
|
low = compute_patient_drift_score(self._pd_row(reaction_day_return=0.02))
|
|
high = compute_patient_drift_score(self._pd_row(reaction_day_return=0.10))
|
|
assert high > low
|
|
|
|
def test_higher_close_location_scores_higher(self):
|
|
low = compute_patient_drift_score(self._pd_row(close_location=0.45))
|
|
high = compute_patient_drift_score(self._pd_row(close_location=0.85))
|
|
assert high > low
|
|
|
|
def test_nlp_features_dont_affect_score(self):
|
|
base = compute_patient_drift_score(self._pd_row())
|
|
with_nlp = compute_patient_drift_score(self._pd_row(
|
|
document_quality_score=0.90,
|
|
guidance_direction_score=1.0,
|
|
signal_strength_score=0.95,
|
|
))
|
|
assert with_nlp == pytest.approx(base)
|
|
|
|
|
|
class TestMicrostructureScore:
|
|
"""Microstructure scoring — event-agnostic, pure price signals."""
|
|
|
|
def _ms_row(self, **overrides):
|
|
base = {
|
|
"reaction_day_return": 0.03,
|
|
"close_location": 0.80,
|
|
"volume_ratio_20d": 0.7,
|
|
"gap_size": 0.005,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_quiet_conviction_setup_scores_well(self):
|
|
score = compute_microstructure_score(self._ms_row())
|
|
assert score > 0.45
|
|
|
|
def test_score_bounded_0_to_1(self):
|
|
extremes = [
|
|
self._ms_row(reaction_day_return=0.001, close_location=0.1),
|
|
self._ms_row(reaction_day_return=0.20, close_location=1.0),
|
|
]
|
|
for row in extremes:
|
|
score = compute_microstructure_score(row)
|
|
assert 0.0 <= score <= 1.0
|
|
|
|
def test_accepts_any_event_type(self):
|
|
for event_type in ["earnings_release", "guidance_update", "management_change",
|
|
"material_contract", "other_material_event"]:
|
|
score = compute_microstructure_score(self._ms_row(event_type=event_type))
|
|
assert score > 0.0
|
|
|
|
def test_rejects_negative_reaction(self):
|
|
assert compute_microstructure_score(self._ms_row(reaction_day_return=-0.02)) == 0.0
|
|
assert compute_microstructure_score(self._ms_row(reaction_day_return=0.0)) == 0.0
|
|
|
|
def test_lower_volume_scores_higher(self):
|
|
"""Below-average volume = quiet conviction = higher score."""
|
|
quiet = compute_microstructure_score(self._ms_row(volume_ratio_20d=0.6))
|
|
loud = compute_microstructure_score(self._ms_row(volume_ratio_20d=1.8))
|
|
assert quiet > loud
|
|
|
|
def test_higher_close_location_scores_higher(self):
|
|
low = compute_microstructure_score(self._ms_row(close_location=0.55))
|
|
high = compute_microstructure_score(self._ms_row(close_location=0.90))
|
|
assert high > low
|
|
|
|
def test_nlp_features_dont_affect_score(self):
|
|
base = compute_microstructure_score(self._ms_row())
|
|
with_nlp = compute_microstructure_score({
|
|
**self._ms_row(),
|
|
"document_quality_score": 0.90,
|
|
"guidance_direction_score": 1.0,
|
|
"event_direction": "bullish",
|
|
})
|
|
assert with_nlp == pytest.approx(base)
|
|
|
|
def test_no_event_type_still_works(self):
|
|
score = compute_microstructure_score(self._ms_row())
|
|
assert score > 0.0
|
|
|
|
|
|
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
|
|
|
|
|
|
class TestReturnMaxLongScoreV11:
|
|
"""Positive-only auxiliary bonuses should gently perturb V5, not flip it."""
|
|
|
|
@staticmethod
|
|
def _base_row() -> dict[str, float | str]:
|
|
return {
|
|
"event_type": "earnings_release",
|
|
"event_direction": "bullish",
|
|
"guidance_status": "raised",
|
|
"reaction_day_return": 0.012,
|
|
"close_location": 0.72,
|
|
"volume_ratio_20d": 1.45,
|
|
"gap_size": 0.008,
|
|
"oneoff_penalty": 0.05,
|
|
"avg_dollar_volume_20d": 250000000.0,
|
|
"market_cap": 25000000000.0,
|
|
"parse_confidence_overall": 0.85,
|
|
"signal_strength_score": 0.8,
|
|
"guidance_direction_score": 0.9,
|
|
"document_quality_score": 0.75,
|
|
}
|
|
|
|
def test_v11_adds_small_bonus_when_aux_signals_are_favorable(self):
|
|
row = self._base_row() | {
|
|
"prior_event_fwd5d": 0.08,
|
|
"macro_vix": 22.0,
|
|
"macro_hy_spread": 3.8,
|
|
}
|
|
baseline = compute_return_max_long_score_v5(row)
|
|
v11 = compute_return_max_long_score_v11(row)
|
|
v11g = compute_return_max_long_score_v11g(row)
|
|
|
|
assert v11 > baseline
|
|
assert v11g > baseline
|
|
assert v11 > v11g
|
|
|
|
def test_v11_does_not_penalize_when_aux_signals_are_adverse(self):
|
|
row = self._base_row() | {
|
|
"prior_event_fwd5d": -0.08,
|
|
"macro_vix": 13.0,
|
|
"macro_hy_spread": 2.9,
|
|
}
|
|
baseline = compute_return_max_long_score_v5(row)
|
|
v11 = compute_return_max_long_score_v11(row)
|
|
v11g = compute_return_max_long_score_v11g(row)
|
|
|
|
assert v11 == pytest.approx(baseline, abs=1e-9)
|
|
assert v11g == pytest.approx(baseline, abs=1e-9)
|