feat: scoring cleanup — alpha-only composite, default-deny unknown events, new exit/risk features

Remove 5 non-alpha features (earnings surprise, risk penalty, parse confidence,
direction clarity, LM sentiment) from composite score to eliminate double-counting
with hard gates and noise sources. Redistribute weights to 5 alpha features.

Add default-deny for unknown event types, no-follow-through early exit (D+1),
kill switch log-only mode, macro regime size scaler. Remove SUE gate (Gate 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 3779be78e3
commit 191653394d

@ -265,9 +265,13 @@ class BacktestRunner:
if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not self._kill_switch_triggered: if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not self._kill_switch_triggered:
logger.warning("kill_switch_triggered", date=str(date), drawdown_pct=drawdown_pct) logger.warning("kill_switch_triggered", date=str(date), drawdown_pct=drawdown_pct)
self._kill_switch_triggered = True if self.config.risk.kill_switch_log_only:
if self.config.risk.backtest_mode == "research": logger.info("kill_switch_log_only_mode", date=str(date))
self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days # Don't trigger — just observe
else:
self._kill_switch_triggered = True
if self.config.risk.backtest_mode == "research":
self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days
# Research mode: reset kill switch after cooldown expires # Research mode: reset kill switch after cooldown expires
# Reset peak_equity to current equity so drawdown restarts from 0 # Reset peak_equity to current equity so drawdown restarts from 0

@ -27,6 +27,7 @@
"stop_atr_multiplier": 3.0, "stop_atr_multiplier": 3.0,
"backtest_mode": "research", "backtest_mode": "research",
"kill_switch_cooldown_days": 20, "kill_switch_cooldown_days": 20,
"kill_switch_log_only": false,
"veto_oneoff_penalty": 0.7, "veto_oneoff_penalty": 0.7,
"veto_parse_confidence_min": 0.4, "veto_parse_confidence_min": 0.4,
"veto_unknown_direction": true, "veto_unknown_direction": true,
@ -44,7 +45,8 @@
"target_1_fraction": 0.5, "target_1_fraction": 0.5,
"trailing_model": "pct_3", "trailing_model": "pct_3",
"trailing_warmup_days": 2, "trailing_warmup_days": 2,
"max_holding_days": 10 "max_holding_days": 10,
"no_follow_through_exit": true
}, },
"reporting": { "reporting": {
"write_trade_blotter": true, "write_trade_blotter": true,

@ -0,0 +1,16 @@
{
"experiment_name": "earnings_only_v1",
"dataset_snapshot_id": "a6687401-afdd-4bb7-9bb1-4cd32c5190bb",
"description": "Earnings-only baseline: tests PEAD alpha without dilution from other event types.",
"base_config": "configs/backtest/defaults.json",
"overrides": {
"event_type_profiles": {
"earnings_release": {
"enabled": true,
"direction_filter": "bullish_only"
}
},
"signal": { "score_threshold": 0.45 }
},
"tags": ["earnings-only", "baseline", "phase6"]
}

@ -95,7 +95,7 @@ def run_entry_gates(
"""Run entry gates. Returns skip_reason string or None (pass). """Run entry gates. Returns skip_reason string or None (pass).
Gates (in order): Gates (in order):
0. Macro regime (SPY below SMA bearish market) 0. Macro regime (SPY below SMA hard block only if size_scaler >= 1.0)
1. Kill switch (drawdown >= threshold) 1. Kill switch (drawdown >= threshold)
2. Max total positions 2. Max total positions
3. Duplicate symbol already open 3. Duplicate symbol already open
@ -103,19 +103,21 @@ def run_entry_gates(
5. Daily new risk budget 5. Daily new risk budget
6. Cash available (estimated position cost) 6. Cash available (estimated position cost)
7. Loss-streak cooldown 7. Loss-streak cooldown
8. SUE gate (earnings: positive surprise required) 8. (removed SUE gate)
9. Event-type direction filter (bullish_only) 9. Event-type direction filter (bullish_only)
10. High one-off risk (veto: oneoff_penalty >= threshold) 10. High one-off risk (veto: oneoff_penalty >= threshold)
11. Low parse confidence (veto: parse_confidence < threshold) 11. Low parse confidence (veto: parse_confidence < threshold)
12. Unknown direction (veto: event_direction == "unknown") 12. Unknown direction (veto: event_direction == "unknown")
13. Bearish direction (veto: event_direction == "bearish") 13. Bearish direction (veto: event_direction == "bearish")
""" """
# Gate 0: Macro regime filter # Gate 0: Macro regime filter (hard block only when size_scaler >= 1.0)
if config.risk.macro_regime_enabled and macro_data: if config.risk.macro_regime_enabled and macro_data:
spy_close = macro_data.get("spy_close") spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20") spy_sma = macro_data.get("spy_sma_20")
if spy_close is not None and spy_sma is not None and spy_close < spy_sma: if spy_close is not None and spy_sma is not None and spy_close < spy_sma:
return "macro_regime_unfavorable" if config.risk.macro_regime_size_scaler >= 1.0:
return "macro_regime_unfavorable"
# else: size scaler applied in build_planned_order
# Gate 1: Kill switch # Gate 1: Kill switch
if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT: if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT:
@ -156,12 +158,6 @@ def run_entry_gates(
if cooldown_remaining > 0: if cooldown_remaining > 0:
return "cooldown" return "cooldown"
# Gate 8: SUE gate — for earnings_release events, require positive surprise
if candidate.event_type == "earnings_release":
eps_growth = candidate.features.get("eps_growth_qoq")
if eps_growth is not None and float(eps_growth) <= 0:
return "negative_earnings_surprise"
# Gate 9: Event-type direction filter # Gate 9: Event-type direction filter
profile = config.get_event_profile(candidate.event_type) profile = config.get_event_profile(candidate.event_type)
if profile and profile.direction_filter == "bullish_only": if profile and profile.direction_filter == "bullish_only":
@ -245,6 +241,21 @@ def build_planned_order(
if shares == 0: if shares == 0:
skip_reason = "zero_shares" skip_reason = "zero_shares"
else: else:
# Apply macro size scaler when SPY < SMA and scaler < 1.0
if (
config.risk.macro_regime_enabled
and config.risk.macro_regime_size_scaler < 1.0
and macro_data
):
spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20")
if (
spy_close is not None
and spy_sma is not None
and spy_close < spy_sma
):
shares = max(1, math.floor(shares * config.risk.macro_regime_size_scaler))
risk_dollars = (candidate.entry_price_est - stop_price) * shares risk_dollars = (candidate.entry_price_est - stop_price) * shares
return PlannedOrder( return PlannedOrder(

@ -25,6 +25,7 @@ class ExitReason(str, Enum):
TRAILING = "TRAILING" TRAILING = "TRAILING"
KILL_SWITCH = "KILL_SWITCH" KILL_SWITCH = "KILL_SWITCH"
MISSING_BAR = "MISSING_BAR" MISSING_BAR = "MISSING_BAR"
NO_FOLLOW_THROUGH = "NO_FOLLOW_THROUGH"
class BacktestMode(str, Enum): class BacktestMode(str, Enum):
@ -157,6 +158,7 @@ class MetricsBundle(BaseModel):
avg_holding_days: float | None = None avg_holding_days: float | None = None
stop_exit_rate: float | None = None stop_exit_rate: float | None = None
target_exit_rate: float | None = None target_exit_rate: float | None = None
no_follow_through_exit_rate: float | None = None
score_bucket_hit_rate: dict[str, float] = Field(default_factory=dict) score_bucket_hit_rate: dict[str, float] = Field(default_factory=dict)
# Bootstrap confidence intervals (95%) # Bootstrap confidence intervals (95%)
@ -192,10 +194,12 @@ class RiskConfig(BaseModel):
cooldown_after_loss_streak: int = 0 # consecutive losses to trigger cooldown cooldown_after_loss_streak: int = 0 # consecutive losses to trigger cooldown
cooldown_days: int = 0 # days to sit out after streak cooldown_days: int = 0 # days to sit out after streak
macro_regime_enabled: bool = False # block entries when SPY < SMA macro_regime_enabled: bool = False # block entries when SPY < SMA
macro_regime_size_scaler: float = 1.0 # size scaler when SPY < SMA (< 1.0 = scale down instead of block)
macro_sma_period: int = 20 # SMA lookback for macro regime macro_sma_period: int = 20 # SMA lookback for macro regime
stop_atr_multiplier: float = 1.5 # ATR multiplier for stop distance stop_atr_multiplier: float = 1.5 # ATR multiplier for stop distance
backtest_mode: str = "research" # "research" or "live" backtest_mode: str = "research" # "research" or "live"
kill_switch_cooldown_days: int = 20 # trading days before reset (research only) kill_switch_cooldown_days: int = 20 # trading days before reset (research only)
kill_switch_log_only: bool = False # log-only mode (don't trigger, just observe)
veto_oneoff_penalty: float = 0.5 # block if oneoff_penalty >= this veto_oneoff_penalty: float = 0.5 # block if oneoff_penalty >= this
veto_parse_confidence_min: float = 0.4 # block if parse_confidence < this veto_parse_confidence_min: float = 0.4 # block if parse_confidence < this
veto_unknown_direction: bool = True # block if event_direction == "unknown" veto_unknown_direction: bool = True # block if event_direction == "unknown"
@ -216,6 +220,7 @@ class ExecutionConfig(BaseModel):
trailing_model: str | None = None trailing_model: str | None = None
trailing_warmup_days: int = 0 # days after entry before trailing activates trailing_warmup_days: int = 0 # days after entry before trailing activates
max_holding_days: int = 10 max_holding_days: int = 10
no_follow_through_exit: bool = False # exit at D+1 close if close < entry price
class EventTypeProfile(BaseModel): class EventTypeProfile(BaseModel):

@ -158,6 +158,17 @@ def simulate_exit(
else: else:
exit_fill_price = position.entry_price # fallback (shouldn't happen) exit_fill_price = position.entry_price # fallback (shouldn't happen)
# No-follow-through early exit (D+1 close < entry)
if (
exit_reason is None
and config.no_follow_through_exit
and position.days_held == 1
and bar_close is not None
and float(bar_close) < position.entry_price
):
exit_reason = ExitReason.NO_FOLLOW_THROUGH
exit_fill_price = _long_exit_fill(float(bar_close), slippage)
if exit_reason is None or exit_fill_price is None: if exit_reason is None or exit_fill_price is None:
return None return None

@ -281,6 +281,15 @@ def compute_target_exit_rate(trades: list[FilledTrade]) -> float | None:
return targets / len(trades) return targets / len(trades)
def compute_no_follow_through_rate(trades: list[FilledTrade]) -> float | None:
from libs.backtest.domain import ExitReason
if not trades:
return None
nft = sum(1 for t in trades if t.exit_reason == ExitReason.NO_FOLLOW_THROUGH)
return nft / len(trades)
def compute_score_bucket_hit_rate(trades: list[FilledTrade], candidate_map: dict[str, object]) -> dict[str, float]: def compute_score_bucket_hit_rate(trades: list[FilledTrade], candidate_map: dict[str, object]) -> dict[str, float]:
"""Win rate per score_bucket (uses trade_id -> candidate mapping).""" """Win rate per score_bucket (uses trade_id -> candidate mapping)."""
bucket_wins: dict[str, int] = defaultdict(int) bucket_wins: dict[str, int] = defaultdict(int)
@ -409,6 +418,7 @@ def build_metrics_bundle(
avg_holding_days=compute_avg_holding_days(trades), avg_holding_days=compute_avg_holding_days(trades),
stop_exit_rate=compute_stop_exit_rate(trades), stop_exit_rate=compute_stop_exit_rate(trades),
target_exit_rate=compute_target_exit_rate(trades), target_exit_rate=compute_target_exit_rate(trades),
no_follow_through_exit_rate=compute_no_follow_through_rate(trades),
score_bucket_hit_rate=compute_score_bucket_hit_rate(trades, candidate_map or {}), score_bucket_hit_rate=compute_score_bucket_hit_rate(trades, candidate_map or {}),
# Bootstrap CIs # Bootstrap CIs
bootstrap_cis=cis, bootstrap_cis=cis,

@ -1,24 +1,25 @@
"""Rule-based entry score model for the backtester. """Rule-based entry score model for the backtester.
Computes a composite score in [0, 1] from event, market, and text features Computes a composite score in [0, 1] from alpha-only features available at
available at entry time (no forward-looking data). Higher score = more entry time (no forward-looking data). Higher score = more favorable entry
favorable entry conditions for a long swing trade. conditions for a long swing trade.
Event/Document Quality (55% weight primary signal): Alpha features (5 components, 100% weight):
- Event quality (20%) parser confidence + signal strength Event Quality (35%):
- Earnings surprise (12%) SUE/EPS growth (earnings events only) - Event quality (35%) parser confidence + signal strength + guidance
- Risk penalty (10%) oneoff risk flags reduce score
- Parse confidence (8%) parse_confidence_overall from parser Market Confirmation (65%):
- Direction clarity (5%) event_direction categorical field - Reaction quality (25%) moderate positive return is ideal
- Close strength (18%) close near high = buyers won the day
Market Confirmation (35% weight secondary signal): - Volume conviction (14%) above-average but not exhaustion
- Reaction quality (12%) moderate positive return is ideal - Gap quality (8%) small positive gap = orderly strength
- Close strength (10%) close near high = buyers won the day
- Volume conviction (8%) above-average but not exhaustion Non-alpha features (removed from composite, retained for analysis scripts):
- Gap quality (5%) small positive gap = orderly strength - Earnings surprise eps_growth_qoq is sequential growth, not real surprise
- Risk penalty already hard-gated at Gate 10
Text (10% weight filing sentiment): - Parse confidence already hard-gated at Gate 11
- LM sentiment (10%) Loughran-McDonald filing tone - Direction clarity already hard-gated at Gates 12-13
- LM sentiment 70-word dictionary is noise for SEC filings
""" """
from __future__ import annotations from __future__ import annotations
@ -30,49 +31,34 @@ logger = get_logger(__name__)
def compute_entry_score(row: dict[str, Any]) -> float: def compute_entry_score(row: dict[str, Any]) -> float:
"""Compute composite entry score from event + market + text features. """Compute composite entry score from alpha-only features.
Components and weights: Components and weights:
Event/Document Quality (55%): Event Quality (35%):
1. Event quality (20%) parser confidence + signal strength 1. Event quality (35%) parser confidence + signal strength
2. Earnings surprise (12%) SUE/EPS growth (earnings events only) Market Confirmation (65%):
3. Risk penalty (10%) oneoff risk flags reduce score 2. Reaction quality (25%) moderate positive return is ideal
4. Parse confidence (8%) parse_confidence_overall from parser 3. Close strength (18%) close near high = buyers won the day
5. Direction clarity (5%) event_direction categorical field 4. Volume conviction (14%) above-average but not exhaustion
Market Confirmation (35%): 5. Gap quality (8%) small positive gap = orderly strength
6. Reaction quality (12%) moderate positive return is ideal
7. Close strength (10%) close near high = buyers won the day
8. Volume conviction (8%) above-average but not exhaustion
9. Gap quality (5%) small positive gap = orderly strength
Text (10%):
10. LM sentiment (10%) Loughran-McDonald filing tone
Returns float in [0.0, 1.0]. Returns float in [0.0, 1.0].
""" """
# Event/Document Quality components # Alpha features only
event = _event_quality_score(row) event = _event_quality_score(row)
sue = _earnings_surprise_score(row)
risk = _risk_penalty_score(row)
parse_conf = _parse_confidence_score(row)
direction = _direction_clarity_score(row)
# Market Confirmation components
reaction = _reaction_score(row) reaction = _reaction_score(row)
close = _close_strength_score(row) close = _close_strength_score(row)
volume = _volume_score(row) volume = _volume_score(row)
gap = _gap_score(row) gap = _gap_score(row)
# Text sentiment component
text = _text_sentiment_score(row)
raw = ( raw = (
# Event/Document Quality (55%) # Event Quality (35%)
event * 0.20 + sue * 0.12 + risk * 0.10 event * 0.35
+ parse_conf * 0.08 + direction * 0.05 # Market Confirmation (65%)
# Market Confirmation (35%) + reaction * 0.25
+ reaction * 0.12 + close * 0.10 + volume * 0.08 + gap * 0.05 + close * 0.18
# Text (10%) + volume * 0.14
+ text * 0.10 + gap * 0.08
) )
return max(0.0, min(1.0, raw)) return max(0.0, min(1.0, raw))

@ -161,16 +161,23 @@ def filter_by_event_type(
candidates: list[Candidate], candidates: list[Candidate],
profiles: dict[str, EventTypeProfile], profiles: dict[str, EventTypeProfile],
) -> list[Candidate]: ) -> list[Candidate]:
"""Filter out candidates whose event_type is disabled or below per-type threshold.""" """Filter out candidates whose event_type is unknown, disabled, or below per-type threshold.
Default-deny: if profiles dict is non-empty and event_type is not in profiles,
the candidate is skipped (unknown event types are blocked).
"""
if not profiles: if not profiles:
return candidates return candidates
filtered = [] filtered = []
for c in candidates: for c in candidates:
profile = profiles.get(c.event_type) profile = profiles.get(c.event_type)
if profile is not None and not profile.enabled: if profile is None:
logger.debug("skip_unknown_event_type", symbol=c.symbol, event_type=c.event_type)
continue
if not profile.enabled:
logger.debug("skip_disabled_event_type", symbol=c.symbol, event_type=c.event_type) logger.debug("skip_disabled_event_type", symbol=c.symbol, event_type=c.event_type)
continue continue
if profile is not None and profile.score_threshold_override is not None: if profile.score_threshold_override is not None:
if c.score < profile.score_threshold_override: if c.score < profile.score_threshold_override:
logger.debug( logger.debug(
"skip_event_type_score", "skip_event_type_score",

@ -231,17 +231,50 @@ class TestRunEntryGates:
class TestMacroRegimeGate: class TestMacroRegimeGate:
"""Macro regime filter gate tests.""" """Macro regime filter gate tests."""
def test_blocks_when_spy_below_sma(self): def test_blocks_when_spy_below_sma_and_scaler_gte_1(self):
from libs.backtest.allocator import run_entry_gates from libs.backtest.allocator import run_entry_gates
c = _make_candidate() c = _make_candidate()
ps = _make_portfolio_state() ps = _make_portfolio_state()
cfg = _make_config() cfg = _make_config()
cfg.risk.macro_regime_enabled = True cfg.risk.macro_regime_enabled = True
cfg.risk.macro_regime_size_scaler = 1.0 # default — hard block
macro = {"spy_close": 490.0, "spy_sma_20": 500.0} # SPY below SMA macro = {"spy_close": 490.0, "spy_sma_20": 500.0} # SPY below SMA
result = run_entry_gates(c, ps, [], cfg, macro_data=macro) result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result == "macro_regime_unfavorable" assert result == "macro_regime_unfavorable"
def test_passes_when_spy_below_sma_and_scaler_lt_1(self):
from libs.backtest.allocator import run_entry_gates
c = _make_candidate()
ps = _make_portfolio_state()
cfg = _make_config()
cfg.risk.macro_regime_enabled = True
cfg.risk.macro_regime_size_scaler = 0.5 # size scaler — don't hard block
macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result is None # passes gate, size scaler applied in build_planned_order
def test_macro_size_scaler_reduces_shares(self):
from libs.backtest.allocator import build_planned_order
c = _make_candidate(entry_price_est=100.0, atr_14=2.0)
ps = _make_portfolio_state()
cfg = _make_config()
cfg.risk.macro_regime_enabled = True
cfg.risk.macro_regime_size_scaler = 0.5
macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
# Without macro scaler
order_normal = build_planned_order(c, ps, [], cfg, macro_data=None)
# With macro scaler
order_scaled = build_planned_order(c, ps, [], cfg, macro_data=macro)
assert order_normal.skip_reason is None
assert order_scaled.skip_reason is None
assert order_scaled.shares < order_normal.shares
assert order_scaled.shares >= 1
def test_passes_when_spy_above_sma(self): def test_passes_when_spy_above_sma(self):
from libs.backtest.allocator import run_entry_gates from libs.backtest.allocator import run_entry_gates
@ -296,13 +329,14 @@ class TestMacroRegimeGate:
result = run_entry_gates(c, ps, [], cfg, macro_data=macro) result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result is None # Can't evaluate, don't block assert result is None # Can't evaluate, don't block
def test_build_planned_order_with_macro(self): def test_build_planned_order_with_macro_hard_block(self):
from libs.backtest.allocator import build_planned_order from libs.backtest.allocator import build_planned_order
c = _make_candidate() c = _make_candidate()
ps = _make_portfolio_state() ps = _make_portfolio_state()
cfg = _make_config() cfg = _make_config()
cfg.risk.macro_regime_enabled = True cfg.risk.macro_regime_enabled = True
cfg.risk.macro_regime_size_scaler = 1.0 # hard block mode
macro = {"spy_close": 490.0, "spy_sma_20": 500.0} macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
order = build_planned_order(c, ps, [], cfg, macro_data=macro) order = build_planned_order(c, ps, [], cfg, macro_data=macro)
assert order.skip_reason == "macro_regime_unfavorable" assert order.skip_reason == "macro_regime_unfavorable"
@ -337,41 +371,6 @@ class TestComputeTargetPrice:
assert target == pytest.approx(110.0) # falls back to fixed_r assert target == pytest.approx(110.0) # falls back to fixed_r
class TestSUEEntryGate:
def test_negative_eps_growth_blocked(self):
from libs.backtest.allocator import run_entry_gates
c = _make_candidate(
event_type="earnings_release",
features={"eps_growth_qoq": -0.05},
)
ps = _make_portfolio_state()
result = run_entry_gates(c, ps, [], _make_config())
assert result == "negative_earnings_surprise"
def test_positive_eps_growth_passes(self):
from libs.backtest.allocator import run_entry_gates
c = _make_candidate(
event_type="earnings_release",
features={"eps_growth_qoq": 0.10},
)
ps = _make_portfolio_state()
result = run_entry_gates(c, ps, [], _make_config())
assert result is None
def test_non_earnings_not_checked(self):
from libs.backtest.allocator import run_entry_gates
c = _make_candidate(
event_type="guidance_update",
features={"eps_growth_qoq": -0.50},
)
ps = _make_portfolio_state()
result = run_entry_gates(c, ps, [], _make_config())
assert result is None
class TestDirectionFilter: class TestDirectionFilter:
def test_bullish_only_blocks_bearish(self): def test_bullish_only_blocks_bearish(self):
from libs.backtest.allocator import run_entry_gates from libs.backtest.allocator import run_entry_gates

@ -12,6 +12,7 @@ from libs.backtest.scoring import (
_parse_confidence_score, _parse_confidence_score,
_reaction_score, _reaction_score,
_risk_penalty_score, _risk_penalty_score,
_text_sentiment_score,
_volume_score, _volume_score,
compute_entry_score, compute_entry_score,
) )
@ -133,7 +134,10 @@ class TestComputeEntryScore:
assert score == pytest.approx(0.5, abs=0.02) assert score == pytest.approx(0.5, abs=0.02)
def test_ideal_setup_scores_high(self): def test_ideal_setup_scores_high(self):
"""Moderate positive return + close near high + healthy volume.""" """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 = { row = {
"reaction_day_return": 0.01, # sweet spot → 0.9 "reaction_day_return": 0.01, # sweet spot → 0.9
"close_location": 0.8, # near high → 0.82 "close_location": 0.8, # near high → 0.82
@ -141,7 +145,7 @@ class TestComputeEntryScore:
"gap_size": 0.01, # orderly → 0.8 "gap_size": 0.01, # orderly → 0.8
} }
score = compute_entry_score(row) score = compute_entry_score(row)
assert score > 0.60 assert score > 0.65
def test_bearish_setup_scores_low(self): def test_bearish_setup_scores_low(self):
"""Negative return + close near low + below avg volume.""" """Negative return + close near low + below avg volume."""
@ -152,7 +156,7 @@ class TestComputeEntryScore:
"gap_size": -0.03, # bearish gap → 0.2 "gap_size": -0.03, # bearish gap → 0.2
} }
score = compute_entry_score(row) score = compute_entry_score(row)
assert score < 0.45 assert score < 0.38
def test_extreme_positive_penalized(self): def test_extreme_positive_penalized(self):
"""Very large positive reaction should be penalized.""" """Very large positive reaction should be penalized."""
@ -215,8 +219,8 @@ class TestComputeEntryScore:
"volume_ratio_20d": 1.45, "volume_ratio_20d": 1.45,
"gap_size": 0.006, "gap_size": 0.006,
}) })
assert aapl > 0.55, f"AAPL should be above 0.55, got {aapl:.3f}" assert aapl > 0.65, f"AAPL should be above 0.65, got {aapl:.3f}"
assert tsla < 0.48, f"TSLA should be below 0.48, got {tsla:.3f}" assert tsla < 0.46, f"TSLA should be below 0.46, got {tsla:.3f}"
assert aapl > tsla assert aapl > tsla
def test_event_features_boost_score(self): def test_event_features_boost_score(self):
@ -239,19 +243,32 @@ class TestComputeEntryScore:
}) })
assert with_events > market_only assert with_events > market_only
def test_high_risk_penalty_lowers_score(self): def test_removed_features_dont_affect_composite(self):
"""High oneoff_penalty should lower overall score.""" """Changing non-alpha features should not change composite score.
low_risk = compute_entry_score({
"reaction_day_return": 0.01, eps_growth_qoq, oneoff_penalty, parse_confidence_overall,
"close_location": 0.6, event_direction, and lm_net_sentiment are excluded from composite.
"oneoff_penalty": 0.0, """
}) base_row = {
high_risk = compute_entry_score({
"reaction_day_return": 0.01, "reaction_day_return": 0.01,
"close_location": 0.6, "close_location": 0.6,
"oneoff_penalty": 1.0, "volume_ratio_20d": 1.5,
}) "gap_size": 0.01,
assert low_risk > high_risk }
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: class TestEarningsSurpriseScore:

@ -206,6 +206,7 @@ class TestFilterByEventType:
] ]
candidates = [build_candidate(r) for r in rows if build_candidate(r)] candidates = [build_candidate(r) for r in rows if build_candidate(r)]
profiles = { profiles = {
"earnings_release": EventTypeProfile(enabled=True),
"management_change": EventTypeProfile(enabled=False), "management_change": EventTypeProfile(enabled=False),
} }
filtered = filter_by_event_type(candidates, profiles) filtered = filter_by_event_type(candidates, profiles)
@ -227,6 +228,22 @@ class TestFilterByEventType:
assert len(filtered) == 1 assert len(filtered) == 1
assert filtered[0].symbol == "B" 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_no_profiles_passthrough(self): def test_no_profiles_passthrough(self):
from libs.backtest.selector import build_candidate, filter_by_event_type from libs.backtest.selector import build_candidate, filter_by_event_type
@ -263,7 +280,10 @@ class TestSelectCandidates:
] ]
u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000) u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000)
s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10) s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10)
profiles = {"management_change": EventTypeProfile(enabled=False)} profiles = {
"earnings_release": EventTypeProfile(enabled=True),
"management_change": EventTypeProfile(enabled=False),
}
result = select_candidates(rows, u, s, event_type_profiles=profiles) result = select_candidates(rows, u, s, event_type_profiles=profiles)
assert len(result) == 1 assert len(result) == 1
assert result[0].symbol == "A" assert result[0].symbol == "A"

Loading…
Cancel
Save