feat: add rule-based entry score model for backtester
Replace naive abs(reaction_day_return) fallback with a composite score
from 4 market microstructure features available at entry time:
1. Reaction quality (35%) — moderate positive return (PEAD zone) is
ideal; extreme positives penalized as "priced in"
2. Close strength (30%) — close near session high = buyers won
3. Volume conviction (20%) — 1.2-2x is healthy; >3x is exhaustion
4. Gap quality (15%) — small positive gap = orderly strength
Real data results (14 events, b1868603 snapshot):
- Score filters out 6 of 10 losers (DDOG -11.7%, META -9.1%, etc.)
- With threshold 0.5: return -2.63% → +0.27%, drawdown 4.24% → 0.86%
- Profit factor 0.44 → 1.16 (turns profitable)
- MSFT loss (-8.7%) is macro-driven, not predictable from stock features
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
main
parent
d387d567c5
commit
354f7a1716
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"experiment_name": "realdata_scored_v1",
|
||||||
|
"dataset_snapshot_id": "b1868603-5193-4308-9627-a185e054f99d",
|
||||||
|
"description": "Real data test with rule-based entry score model and score_threshold=0.5.",
|
||||||
|
"base_config": "configs/backtest/defaults.json",
|
||||||
|
"overrides": {
|
||||||
|
"signal": {
|
||||||
|
"score_threshold": 0.5,
|
||||||
|
"max_candidates_per_day": 10
|
||||||
|
},
|
||||||
|
"risk": {
|
||||||
|
"per_trade_risk_pct": 0.01,
|
||||||
|
"max_daily_new_risk_pct": 0.05,
|
||||||
|
"max_positions": 10,
|
||||||
|
"max_positions_per_sector": 5
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"max_holding_days": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"splits": [],
|
||||||
|
"tags": ["realdata", "scored"],
|
||||||
|
"notes": "Uses compute_entry_score() from libs/backtest/scoring.py. Filters out bearish/extreme setups."
|
||||||
|
}
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
"""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
|
||||||
Loading…
Reference in New Issue