feat: Phase 5 fundamental strategy improvements

Fix core strategy flaws identified from academic research and Phase 4
backtest results (23% win rate, 0% target hits, 77% stop exits).

5A — Exit mechanics: ATR-based targets (reachable ~4.5% vs unreachable ~6-8%),
     partial profit-taking at target with breakeven stop on remainder,
     wider catastrophic stop (3.0 ATR), trailing stop enabled by default.
5B — Event-type-specific logic: EventTypeProfile with per-type overrides
     for holding days, ATR multipliers, score thresholds, direction filter.
     Disabled management_change and other_material_event (low evidence).
5C-1 — Expanded universe from 15 to 97 symbols across sectors including
       mid-cap growth where PEAD is stronger.
5C-3 — Bootstrap 95% confidence intervals for key trade metrics.
5D — SUE integration: earnings surprise scoring (eps_growth_qoq) at 10%
     weight, entry gate blocks negative EPS surprise for earnings events.
5F — Extended label horizons to 10D/20D with Alembic migration.

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

@ -206,9 +206,21 @@ class BacktestRunner:
# Update trailing stop if configured # Update trailing stop if configured
if self.config.execution.trailing_model: if self.config.execution.trailing_model:
update_trailing_stop(pos, bar) update_trailing_stop(
pos, bar,
self.config.execution.trailing_model,
warmup_days=self.config.execution.trailing_warmup_days,
)
# Build effective execution config with per-event-type overrides
effective_exec = self.config.execution
evt_profile = self.config.get_event_profile(pos.plan.candidate.event_type)
if evt_profile and evt_profile.max_holding_days_override is not None:
effective_exec = self.config.execution.model_copy(
update={"max_holding_days": evt_profile.max_holding_days_override}
)
trade = simulate_exit(pos, bar, self.config.execution, date) trade = simulate_exit(pos, bar, effective_exec, date)
if trade is not None: if trade is not None:
newly_closed.append(trade) newly_closed.append(trade)
else: else:
@ -256,9 +268,12 @@ class BacktestRunner:
self._total_candidates_seen += len(raw_rows) self._total_candidates_seen += len(raw_rows)
portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized) portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized)
candidates = select_candidates( candidates = select_candidates(
raw_rows, self.config.universe, self.config.signal raw_rows, self.config.universe, self.config.signal,
event_type_profiles=self.config.event_type_profiles or None,
) )
macro_data = self.store.get_macro_for_date(date)
for candidate in candidates: for candidate in candidates:
plan = build_planned_order( plan = build_planned_order(
candidate=candidate, candidate=candidate,
@ -266,6 +281,7 @@ class BacktestRunner:
open_positions=self._open_positions, open_positions=self._open_positions,
config=self.config, config=self.config,
cooldown_remaining=self._cooldown_remaining, cooldown_remaining=self._cooldown_remaining,
macro_data=macro_data,
) )
if plan.skip_reason is not None: if plan.skip_reason is not None:

@ -21,7 +21,10 @@
"max_position_value_pct": 0.10, "max_position_value_pct": 0.10,
"max_adv_fraction": 0.01, "max_adv_fraction": 0.01,
"cooldown_after_loss_streak": 3, "cooldown_after_loss_streak": 3,
"cooldown_days": 2 "cooldown_days": 2,
"macro_regime_enabled": false,
"macro_sma_period": 20,
"stop_atr_multiplier": 3.0
}, },
"execution": { "execution": {
"entry_fill_model": "next_open", "entry_fill_model": "next_open",
@ -29,8 +32,12 @@
"slippage_bps_base": 10.0, "slippage_bps_base": 10.0,
"commission_per_share": 0.005, "commission_per_share": 0.005,
"same_bar_priority": "stop_first_conservative", "same_bar_priority": "stop_first_conservative",
"target_model": "atr_multiple",
"target_1_r": 2.0, "target_1_r": 2.0,
"target_1_fraction": 1.0, "target_atr_multiplier": 1.5,
"target_1_fraction": 0.5,
"trailing_model": "pct_3",
"trailing_warmup_days": 2,
"max_holding_days": 10 "max_holding_days": 10
}, },
"reporting": { "reporting": {
@ -39,5 +46,26 @@
"write_metrics_summary": true, "write_metrics_summary": true,
"generate_plots": false, "generate_plots": false,
"attribution_buckets": ["event_type", "sector", "score_bucket"] "attribution_buckets": ["event_type", "sector", "score_bucket"]
},
"event_type_profiles": {
"earnings_release": {
"enabled": true,
"max_holding_days_override": 15,
"direction_filter": "bullish_only"
},
"guidance_update": {
"enabled": true,
"max_holding_days_override": 10
},
"management_change": {
"enabled": false
},
"material_contract": {
"enabled": true,
"max_holding_days_override": 5
},
"other_material_event": {
"enabled": false
}
} }
} }

@ -12,10 +12,16 @@
"per_trade_risk_pct": 0.01, "per_trade_risk_pct": 0.01,
"max_daily_new_risk_pct": 0.05, "max_daily_new_risk_pct": 0.05,
"max_positions": 10, "max_positions": 10,
"max_positions_per_sector": 5 "max_positions_per_sector": 5,
"macro_regime_enabled": true,
"macro_sma_period": 20,
"stop_atr_multiplier": 2.0
}, },
"execution": { "execution": {
"max_holding_days": 5 "max_holding_days": 5,
"target_1_r": 1.5,
"trailing_model": "pct_3",
"trailing_warmup_days": 2
} }
}, },
"splits": [], "splits": [],

@ -1,4 +1,5 @@
symbols: symbols:
# --- Mega-cap tech (original 15) ---
- AAPL - AAPL
- MSFT - MSFT
- GOOGL - GOOGL
@ -14,3 +15,88 @@ symbols:
- DDOG - DDOG
- ZS - ZS
- CRWD - CRWD
# --- Large-cap tech additions ---
- ADBE
- ORCL
- INTC
- QCOM
- AVGO
- MU
- PANW
- FTNT
- NOW
- WDAY
- SHOP
- SQ
- MELI
- UBER
- DASH
# --- Mid-cap tech / growth ($5B-50B — stronger PEAD) ---
- BILL
- HUBS
- PCOR
- CFLT
- MNDY
- GTLB
- S
- IOT
- DOCN
- BRZE
# --- Healthcare ---
- UNH
- JNJ
- LLY
- ABBV
- PFE
- MRK
- TMO
- ABT
- ISRG
- DXCM
- VEEV
- HIMS
# --- Industrials ---
- CAT
- DE
- GE
- HON
- RTX
- LMT
- EMR
- ETN
- URI
- AXON
# --- Consumer ---
- COST
- WMT
- MCD
- SBUX
- NKE
- TGT
- HD
- LOW
- LULU
- DPZ
# --- Financials ---
- JPM
- GS
- V
- MA
- AXP
- BLK
- COIN
- HOOD
- SOFI
- NU
# --- Energy ---
- XOM
- CVX
- COP
- EOG
- FANG
# --- Communication / Media ---
- DIS
- CMCSA
- NFLX
- SPOT
- RBLX

@ -8,6 +8,7 @@ from libs.backtest.domain import (
BacktestConfig, BacktestConfig,
Candidate, Candidate,
DailyPortfolioState, DailyPortfolioState,
EventTypeProfile,
OpenPosition, OpenPosition,
PlannedOrder, PlannedOrder,
RiskConfig, RiskConfig,
@ -28,7 +29,7 @@ def compute_stop_price(candidate: Candidate, config: RiskConfig) -> float:
""" """
price = candidate.entry_price_est price = candidate.entry_price_est
if candidate.atr_14 and candidate.atr_14 > 0: if candidate.atr_14 and candidate.atr_14 > 0:
stop_distance = candidate.atr_14 * 1.5 stop_distance = candidate.atr_14 * config.stop_atr_multiplier
else: else:
# Fallback: 2% of price # Fallback: 2% of price
stop_distance = price * 0.02 stop_distance = price * 0.02
@ -39,8 +40,21 @@ def compute_target_price(
entry_price_est: float, entry_price_est: float,
stop_price: float, stop_price: float,
target_r: float = 2.0, target_r: float = 2.0,
*,
target_model: str = "fixed_r",
target_atr_multiplier: float = 1.5,
atr_14: float | None = None,
) -> float: ) -> float:
"""Compute target price at target_r multiples of risk.""" """Compute target price using fixed R-multiple or ATR-based model.
Models:
- "fixed_r": target = entry + risk * target_r (original)
- "atr_multiple": target = entry + atr_14 * target_atr_multiplier
"""
if target_model == "atr_multiple" and atr_14 and atr_14 > 0:
return entry_price_est + atr_14 * target_atr_multiplier
# Default: fixed R-multiple
risk = entry_price_est - stop_price risk = entry_price_est - stop_price
if risk <= 0: if risk <= 0:
return entry_price_est * 1.10 # 10% default target return entry_price_est * 1.10 # 10% default target
@ -76,10 +90,12 @@ def run_entry_gates(
open_positions: list[OpenPosition], open_positions: list[OpenPosition],
config: BacktestConfig, config: BacktestConfig,
cooldown_remaining: int = 0, cooldown_remaining: int = 0,
macro_data: dict[str, Any] | None = None,
) -> str | None: ) -> str | None:
"""Run 7-step entry gate. Returns skip_reason string or None (pass). """Run 8-step entry gate. Returns skip_reason string or None (pass).
Gates (in order): Gates (in order):
0. Macro regime (SPY below SMA bearish market)
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
@ -88,6 +104,13 @@ def run_entry_gates(
6. Cash available (estimated position cost) 6. Cash available (estimated position cost)
7. Loss-streak cooldown 7. Loss-streak cooldown
""" """
# Gate 0: Macro regime filter
if config.risk.macro_regime_enabled 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:
return "macro_regime_unfavorable"
# 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:
return "kill_switch_drawdown" return "kill_switch_drawdown"
@ -127,6 +150,19 @@ 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
profile = config.get_event_profile(candidate.event_type)
if profile and profile.direction_filter == "bullish_only":
reaction = candidate.features.get("reaction_day_return")
if reaction is not None and float(reaction) < 0:
return "direction_filter_bearish"
return None # all gates passed return None # all gates passed
@ -136,15 +172,39 @@ def build_planned_order(
open_positions: list[OpenPosition], open_positions: list[OpenPosition],
config: BacktestConfig, config: BacktestConfig,
cooldown_remaining: int = 0, cooldown_remaining: int = 0,
macro_data: dict[str, Any] | None = None,
) -> PlannedOrder: ) -> PlannedOrder:
"""Build a PlannedOrder. skip_reason is non-None if any gate rejected it.""" """Build a PlannedOrder. skip_reason is non-None if any gate rejected it."""
skip_reason = run_entry_gates( skip_reason = run_entry_gates(
candidate, portfolio_state, open_positions, config, cooldown_remaining candidate, portfolio_state, open_positions, config, cooldown_remaining,
macro_data=macro_data,
) )
stop_price = compute_stop_price(candidate, config.risk) # Apply event-type-specific overrides for stop/target ATR multipliers
profile = config.get_event_profile(candidate.event_type)
stop_atr_mult = (
profile.stop_atr_multiplier_override
if profile and profile.stop_atr_multiplier_override is not None
else config.risk.stop_atr_multiplier
)
target_atr_mult = (
profile.target_atr_multiplier_override
if profile and profile.target_atr_multiplier_override is not None
else config.execution.target_atr_multiplier
)
stop_price = compute_stop_price(
candidate, RiskConfig(**{**config.risk.model_dump(), "stop_atr_multiplier": stop_atr_mult})
)
target_r = config.execution.target_1_r or 2.0 target_r = config.execution.target_1_r or 2.0
target_price = compute_target_price(candidate.entry_price_est, stop_price, target_r) target_price = compute_target_price(
candidate.entry_price_est,
stop_price,
target_r,
target_model=config.execution.target_model,
target_atr_multiplier=target_atr_mult,
atr_14=candidate.atr_14,
)
shares = 0 shares = 0
risk_dollars = 0.0 risk_dollars = 0.0

@ -154,6 +154,9 @@ class MetricsBundle(BaseModel):
target_exit_rate: float | None = None target_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_cis: dict[str, tuple[float, float] | None] = Field(default_factory=dict)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config models (mirror JSON Schema) # Config models (mirror JSON Schema)
@ -183,6 +186,9 @@ class RiskConfig(BaseModel):
max_adv_fraction: float | None = None # max fraction of avg daily volume max_adv_fraction: float | None = None # max fraction of avg daily volume
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_sma_period: int = 20 # SMA lookback for macro regime
stop_atr_multiplier: float = 1.5 # ATR multiplier for stop distance
class ExecutionConfig(BaseModel): class ExecutionConfig(BaseModel):
@ -192,12 +198,25 @@ class ExecutionConfig(BaseModel):
commission_per_share: float = 0.005 commission_per_share: float = 0.005
same_bar_priority: str = "stop_first_conservative" same_bar_priority: str = "stop_first_conservative"
stop_model: str | None = None stop_model: str | None = None
target_1_r: float | None = None # R-multiple for first target target_model: str = "fixed_r" # "fixed_r" or "atr_multiple"
target_1_fraction: float | None = None # fraction to exit at target_1 target_1_r: float | None = None # R-multiple for first target (fixed_r model)
target_atr_multiplier: float = 1.5 # ATR multiplier for target (atr_multiple model)
target_1_fraction: float | None = None # fraction to exit at target_1 (partial exit)
trailing_model: str | None = None trailing_model: str | None = None
trailing_warmup_days: int = 0 # days after entry before trailing activates
max_holding_days: int = 10 max_holding_days: int = 10
class EventTypeProfile(BaseModel):
"""Per-event-type overrides for scoring, risk, and exit parameters."""
enabled: bool = True
score_threshold_override: float | None = None
max_holding_days_override: int | None = None
stop_atr_multiplier_override: float | None = None
target_atr_multiplier_override: float | None = None
direction_filter: str = "any" # "bullish_only", "bearish_only", "any"
class ReportingConfig(BaseModel): class ReportingConfig(BaseModel):
write_trade_blotter: bool = True write_trade_blotter: bool = True
write_equity_curve: bool = True write_equity_curve: bool = True
@ -214,6 +233,11 @@ class BacktestConfig(BaseModel):
risk: RiskConfig = Field(default_factory=RiskConfig) risk: RiskConfig = Field(default_factory=RiskConfig)
execution: ExecutionConfig = Field(default_factory=ExecutionConfig) execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
reporting: ReportingConfig = Field(default_factory=ReportingConfig) reporting: ReportingConfig = Field(default_factory=ReportingConfig)
event_type_profiles: dict[str, EventTypeProfile] = Field(default_factory=dict)
def get_event_profile(self, event_type: str) -> EventTypeProfile | None:
"""Look up event-type-specific profile. Returns None if no override."""
return self.event_type_profiles.get(event_type)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

@ -112,11 +112,15 @@ def simulate_exit(
Handles: Handles:
- Stop loss (low stop_price) - Stop loss (low stop_price)
- Target (high target_price) - Target (high target_price) with partial exit support
- Same-bar conflict (controlled by same_bar_priority) - Same-bar conflict (controlled by same_bar_priority)
- Time exit (days_held >= max_holding_days) - Time exit (days_held >= max_holding_days)
- Kill switch / missing bar handled upstream - Kill switch / missing bar handled upstream
Partial exits: when target_1_fraction < 1.0 and target is hit, exits only
that fraction, moves stop to breakeven for remaining shares, and returns
the partial FilledTrade. Remaining shares continue with trailing stop.
Slippage is applied in the unfavorable direction for long positions. Slippage is applied in the unfavorable direction for long positions.
""" """
if bar is None: if bar is None:
@ -157,6 +161,32 @@ def simulate_exit(
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
# --- Partial exit logic ---
fraction = config.target_1_fraction
if (
exit_reason == ExitReason.TARGET
and fraction is not None
and 0.0 < fraction < 1.0
and position.status != PositionStatus.PARTIALLY_EXITED
):
partial_shares = max(1, math.floor(position.shares_open * fraction))
remaining_shares = position.shares_open - partial_shares
if remaining_shares > 0:
# Build partial fill trade
partial_trade = _build_filled_trade_partial(
position, exit_fill_price, exit_reason, current_date, config,
shares=partial_shares,
)
# Mutate position: reduce shares, move stop to breakeven, mark partial
position.shares_open = remaining_shares
position.current_stop = position.entry_price # breakeven stop
position.status = PositionStatus.PARTIALLY_EXITED
position.partial_fills.append(partial_trade)
return partial_trade
return _build_filled_trade(position, exit_fill_price, exit_reason, current_date, config) return _build_filled_trade(position, exit_fill_price, exit_reason, current_date, config)
@ -194,17 +224,43 @@ def simulate_missing_bar_exit(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def update_trailing_stop(position: OpenPosition, bar: dict[str, Any]) -> None: def update_trailing_stop(
"""Ratchet stop up to bar low (never down). Mutates position in place.""" position: OpenPosition,
bar: dict[str, Any],
trailing_model: str = "bar_low",
warmup_days: int = 0,
) -> None:
"""Ratchet stop up based on trailing model (never down). Mutates position in place.
Models:
- "bar_low": trail to each day's low (tightest, aggressive)
- "pct_3": trail at peak_price * (1 - 3%) moderate
- "pct_5": trail at peak_price * (1 - 5%) wider
Args:
warmup_days: Skip trailing until position has been held this many days.
"""
# Always track peak price (even during warmup)
bar_high = bar.get("high")
if bar_high is not None:
position.peak_price = max(position.peak_price, float(bar_high))
# Don't tighten stop during warmup period
if position.days_held < warmup_days:
return
if trailing_model == "bar_low":
bar_low = bar.get("low") bar_low = bar.get("low")
if bar_low is not None: if bar_low is not None:
new_stop = max(position.current_stop, float(bar_low)) new_stop = max(position.current_stop, float(bar_low))
position.current_stop = new_stop position.current_stop = new_stop
elif trailing_model.startswith("pct_"):
# Track peak price try:
bar_high = bar.get("high") trail_pct = float(trailing_model.split("_")[1]) / 100.0
if bar_high is not None: except (IndexError, ValueError):
position.peak_price = max(position.peak_price, float(bar_high)) trail_pct = 0.03
trail_stop = position.peak_price * (1.0 - trail_pct)
position.current_stop = max(position.current_stop, trail_stop)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -212,6 +268,52 @@ def update_trailing_stop(position: OpenPosition, bar: dict[str, Any]) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_filled_trade_partial(
position: OpenPosition,
exit_price: float,
exit_reason: ExitReason,
exit_date: dt.date,
config: ExecutionConfig,
shares: int,
) -> FilledTrade:
"""Build a FilledTrade for a partial exit (specific share count)."""
commission = shares * config.commission_per_share # only exit leg for partial
gross_pnl = (exit_price - position.entry_price) * shares
net_pnl = gross_pnl - commission
entry_price = position.entry_price
pnl_pct = (exit_price - entry_price) / entry_price if entry_price != 0 else 0.0
stop_distance = entry_price - position.plan.stop_price
if stop_distance > 0:
r_multiple = (exit_price - entry_price) / stop_distance
else:
r_multiple = 0.0
holding_days = (exit_date - position.entry_date).days
trade_id = str(uuid.uuid4())
return FilledTrade(
trade_id=trade_id,
position_id=position.position_id,
event_id=position.plan.candidate.event_id,
symbol=position.plan.candidate.symbol,
entry_date=position.entry_date,
exit_date=exit_date,
entry_price=position.entry_price,
exit_price=exit_price,
exit_reason=exit_reason,
shares=shares,
commission=commission,
slippage_bps=config.slippage_bps_base,
gross_pnl=gross_pnl,
net_pnl=net_pnl,
pnl_pct=pnl_pct,
r_multiple=r_multiple,
holding_days=holding_days,
)
def _build_filled_trade( def _build_filled_trade(
position: OpenPosition, position: OpenPosition,
exit_price: float, exit_price: float,

@ -7,6 +7,7 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import math import math
import random
import statistics import statistics
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -299,6 +300,70 @@ def compute_score_bucket_hit_rate(trades: list[FilledTrade], candidate_map: dict
} }
# ---------------------------------------------------------------------------
# Bootstrap confidence intervals
# ---------------------------------------------------------------------------
def bootstrap_ci(
trades: list[FilledTrade],
metric_fn: callable,
n_iterations: int = 1000,
ci_level: float = 0.95,
seed: int = 42,
) -> tuple[float, float] | None:
"""Compute bootstrap confidence interval for a trade-level metric.
Args:
trades: List of FilledTrade objects.
metric_fn: Function that takes list[FilledTrade] and returns float | None.
n_iterations: Number of bootstrap resamples.
ci_level: Confidence level (default 0.95 for 95% CI).
seed: Random seed for reproducibility.
Returns:
(lower, upper) bounds or None if metric can't be computed.
"""
if len(trades) < 5:
return None
rng = random.Random(seed)
results = []
for _ in range(n_iterations):
sample = rng.choices(trades, k=len(trades))
val = metric_fn(sample)
if val is not None:
results.append(val)
if len(results) < n_iterations * 0.5:
return None
results.sort()
alpha = (1 - ci_level) / 2
lo_idx = int(alpha * len(results))
hi_idx = int((1 - alpha) * len(results)) - 1
return (results[lo_idx], results[hi_idx])
def compute_bootstrap_cis(
trades: list[FilledTrade],
n_iterations: int = 1000,
seed: int = 42,
) -> dict[str, tuple[float, float] | None]:
"""Compute 95% bootstrap CIs for key trade metrics."""
metrics_fns = {
"win_rate": compute_win_rate,
"avg_win_pct": compute_avg_win_pct,
"avg_loss_pct": compute_avg_loss_pct,
"profit_factor": compute_profit_factor,
"expectancy_r": compute_expectancy_r,
}
return {
f"{name}_ci_95": bootstrap_ci(trades, fn, n_iterations=n_iterations, seed=seed)
for name, fn in metrics_fns.items()
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Builder # Builder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -314,6 +379,9 @@ def build_metrics_bundle(
ann_ret = compute_annualized_return_pct(equity_curve) ann_ret = compute_annualized_return_pct(equity_curve)
max_dd = compute_max_drawdown_pct(equity_curve) max_dd = compute_max_drawdown_pct(equity_curve)
# Bootstrap CIs (only when enough trades)
cis = compute_bootstrap_cis(trades) if len(trades) >= 5 else {}
return MetricsBundle( return MetricsBundle(
# Trade # Trade
trade_count=len(trades), trade_count=len(trades),
@ -342,4 +410,6 @@ def build_metrics_bundle(
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),
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=cis,
) )

@ -28,14 +28,15 @@ def compute_entry_score(row: dict[str, Any]) -> float:
"""Compute composite entry score from market + event features. """Compute composite entry score from market + event features.
Components and weights: Components and weights:
Market (75%): Market (65%):
1. Reaction quality (25%) moderate positive return is ideal 1. Reaction quality (20%) moderate positive return is ideal
2. Close strength (25%) close near high = buyers won the day 2. Close strength (20%) close near high = buyers won the day
3. Volume conviction (15%) above-average but not exhaustion 3. Volume conviction (15%) above-average but not exhaustion
4. Gap quality (10%) small positive gap = orderly strength 4. Gap quality (10%) small positive gap = orderly strength
Event (25%): Event (35%):
5. Event quality (15%) parser confidence + signal strength 5. Event quality (15%) parser confidence + signal strength
6. Risk penalty (10%) oneoff risk flags reduce score 6. Earnings surprise (10%) SUE/EPS growth (earnings events only)
7. Risk penalty (10%) oneoff risk flags reduce score
Returns float in [0.0, 1.0]. Returns float in [0.0, 1.0].
""" """
@ -47,14 +48,16 @@ def compute_entry_score(row: dict[str, Any]) -> float:
# Event components (gracefully handle missing features) # Event components (gracefully handle missing features)
event = _event_quality_score(row) event = _event_quality_score(row)
sue = _earnings_surprise_score(row)
risk = _risk_penalty_score(row) risk = _risk_penalty_score(row)
raw = ( raw = (
reaction * 0.25 reaction * 0.20
+ close * 0.25 + close * 0.20
+ volume * 0.15 + volume * 0.15
+ gap * 0.10 + gap * 0.10
+ event * 0.15 + event * 0.15
+ sue * 0.10
+ risk * 0.10 + risk * 0.10
) )
return max(0.0, min(1.0, raw)) return max(0.0, min(1.0, raw))
@ -179,6 +182,44 @@ def _gap_score(row: dict[str, Any]) -> float:
return 0.2 return 0.2
# ---------------------------------------------------------------------------
# Earnings surprise (SUE) scoring
# ---------------------------------------------------------------------------
def _earnings_surprise_score(row: dict[str, Any]) -> float:
"""Score based on earnings surprise (eps_growth_qoq as naive SUE proxy).
Only active for earnings_release events; returns neutral 0.5 for others.
Bernard & Thomas (1989): drift magnitude is proportional to surprise.
Mapping:
> +20% EPS growth -> 0.9 (strong beat)
> +5% -> 0.75 (moderate beat)
> -5% -> 0.5 (in-line)
> -20% -> 0.25 (moderate miss)
<= -20% -> 0.1 (severe miss)
"""
event_type = row.get("event_type", "")
if event_type != "earnings_release":
return 0.5
sue = row.get("eps_growth_qoq")
if sue is None:
return 0.5
s = float(sue)
if s > 0.20:
return 0.9
if s > 0.05:
return 0.75
if s > -0.05:
return 0.5
if s > -0.20:
return 0.25
return 0.1
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Event feature scoring # Event feature scoring
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

@ -5,7 +5,7 @@ import datetime as dt
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from libs.backtest.domain import Candidate, SignalConfig, UniverseConfig from libs.backtest.domain import Candidate, EventTypeProfile, SignalConfig, UniverseConfig
from libs.common.logging import get_logger from libs.common.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@ -157,10 +157,38 @@ def truncate_candidates(
return candidates[:max_per_day] return candidates[:max_per_day]
def filter_by_event_type(
candidates: list[Candidate],
profiles: dict[str, EventTypeProfile],
) -> list[Candidate]:
"""Filter out candidates whose event_type is disabled or below per-type threshold."""
if not profiles:
return candidates
filtered = []
for c in candidates:
profile = profiles.get(c.event_type)
if profile is not None and not profile.enabled:
logger.debug("skip_disabled_event_type", symbol=c.symbol, event_type=c.event_type)
continue
if profile is not None and profile.score_threshold_override is not None:
if c.score < profile.score_threshold_override:
logger.debug(
"skip_event_type_score",
symbol=c.symbol,
event_type=c.event_type,
score=c.score,
threshold=profile.score_threshold_override,
)
continue
filtered.append(c)
return filtered
def select_candidates( def select_candidates(
raw_rows: list[dict[str, Any]], raw_rows: list[dict[str, Any]],
universe_config: UniverseConfig, universe_config: UniverseConfig,
signal_config: SignalConfig, signal_config: SignalConfig,
event_type_profiles: dict[str, EventTypeProfile] | None = None,
) -> list[Candidate]: ) -> list[Candidate]:
"""Full selection pipeline: build → filter → rank → truncate.""" """Full selection pipeline: build → filter → rank → truncate."""
candidates = [] candidates = []
@ -171,6 +199,8 @@ def select_candidates(
candidates = filter_by_universe(candidates, universe_config) candidates = filter_by_universe(candidates, universe_config)
candidates = filter_by_score(candidates, signal_config.score_threshold) candidates = filter_by_score(candidates, signal_config.score_threshold)
if event_type_profiles:
candidates = filter_by_event_type(candidates, event_type_profiles)
candidates = rank_candidates(candidates) candidates = rank_candidates(candidates)
candidates = truncate_candidates(candidates, signal_config.max_candidates_per_day) candidates = truncate_candidates(candidates, signal_config.max_candidates_per_day)
return candidates return candidates

@ -141,6 +141,11 @@ class SnapshotStore:
sectors = await cls._fetch_sectors(unique_symbols, oracle_url) sectors = await cls._fetch_sectors(unique_symbols, oracle_url)
macro_by_date = await cls._fetch_macro(date_range, db_dsn) macro_by_date = await cls._fetch_macro(date_range, db_dsn)
# Fetch SPY bars for macro regime filter (SMA computation)
spy_macro = await cls._fetch_spy_macro(date_range, oracle_url)
for d, spy_data in spy_macro.items():
macro_by_date.setdefault(d, {}).update(spy_data)
# Step 7: Build candidates_by_exec_date # Step 7: Build candidates_by_exec_date
candidates_by_exec_date: dict[dt.date, list[dict[str, Any]]] = {} candidates_by_exec_date: dict[dt.date, list[dict[str, Any]]] = {}
for row in row_list: for row in row_list:
@ -369,6 +374,59 @@ class SnapshotStore:
logger.warning("snapshot_store_macro_fetch_failed", error=str(exc)) logger.warning("snapshot_store_macro_fetch_failed", error=str(exc))
return {} return {}
@staticmethod
async def _fetch_spy_macro(
date_range: tuple[dt.date, dt.date] | None,
oracle_url: str,
sma_period: int = 20,
) -> dict[dt.date, dict[str, Any]]:
"""Fetch SPY daily bars and compute SMA for macro regime filtering.
Returns dict: date -> {"spy_close": float, "spy_sma_20": float|None}.
SMA is None for the first (sma_period - 1) bars.
"""
if date_range is None:
return {}
try:
from libs.oracle_client.client import OracleClient
from libs.oracle_client.price import PriceService
# Extend start date back by sma_period trading days for SMA warm-up
warmup_days = sma_period * 2 # calendar days (conservative buffer)
extended_start = date_range[0] - dt.timedelta(days=warmup_days)
async with OracleClient(base_url=oracle_url) as client:
svc = PriceService(client)
resp = await svc.get_daily_bars(
"SPY", start=extended_start.isoformat(), end=date_range[1].isoformat()
)
# Sort bars by date
sorted_bars = sorted(resp.bars, key=lambda b: b.date)
closes: list[tuple[dt.date, float]] = [
(dt.date.fromisoformat(b.date), float(b.close)) for b in sorted_bars
]
result: dict[dt.date, dict[str, Any]] = {}
for i, (d, close) in enumerate(closes):
sma = None
if i >= sma_period - 1:
window = [c for _, c in closes[i - sma_period + 1 : i + 1]]
sma = sum(window) / len(window)
# Only store data within the actual date range
if d >= date_range[0]:
result[d] = {"spy_close": close, "spy_sma_20": sma}
logger.info(
"snapshot_store_spy_macro_loaded",
bars=len(closes),
dates_with_sma=sum(1 for v in result.values() if v.get("spy_sma_20") is not None),
)
return result
except Exception as exc:
logger.warning("snapshot_store_spy_macro_failed", error=str(exc))
return {}
@staticmethod @staticmethod
def _compute_date_range( def _compute_date_range(
rows: list[dict[str, Any]], rows: list[dict[str, Any]],

@ -0,0 +1,39 @@
"""Phase 5: Extended label horizons (10D, 20D forward returns and MFE/MAE).
Revision ID: 0003
Revises: 0002
Create Date: 2026-03-13
Adds columns:
- fwd_return_10d, fwd_return_20d
- mfe_10d, mae_10d, mfe_20d, mae_20d
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("event_labels", sa.Column("fwd_return_10d", sa.Numeric, nullable=True))
op.add_column("event_labels", sa.Column("fwd_return_20d", sa.Numeric, nullable=True))
op.add_column("event_labels", sa.Column("mfe_10d", sa.Numeric, nullable=True))
op.add_column("event_labels", sa.Column("mae_10d", sa.Numeric, nullable=True))
op.add_column("event_labels", sa.Column("mfe_20d", sa.Numeric, nullable=True))
op.add_column("event_labels", sa.Column("mae_20d", sa.Numeric, nullable=True))
def downgrade() -> None:
op.drop_column("event_labels", "mae_20d")
op.drop_column("event_labels", "mfe_20d")
op.drop_column("event_labels", "mae_10d")
op.drop_column("event_labels", "mfe_10d")
op.drop_column("event_labels", "fwd_return_20d")
op.drop_column("event_labels", "fwd_return_10d")

@ -429,6 +429,8 @@ class EventLabel(Base):
fwd_return_1d: Mapped[float | None] = mapped_column(Numeric, nullable=True) fwd_return_1d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
fwd_return_3d: Mapped[float | None] = mapped_column(Numeric, nullable=True) fwd_return_3d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
fwd_return_5d: Mapped[float | None] = mapped_column(Numeric, nullable=True) fwd_return_5d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
fwd_return_10d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
fwd_return_20d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
hit_pos_1r_within_3d: Mapped[bool | None] = mapped_column(Boolean, nullable=True) hit_pos_1r_within_3d: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
hit_neg_1r_within_3d: Mapped[bool | None] = mapped_column(Boolean, nullable=True) hit_neg_1r_within_3d: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
close_up_after_3d: Mapped[bool | None] = mapped_column(Boolean, nullable=True) close_up_after_3d: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
@ -437,6 +439,10 @@ class EventLabel(Base):
mae_3d: Mapped[float | None] = mapped_column(Numeric, nullable=True) mae_3d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
mfe_5d: Mapped[float | None] = mapped_column(Numeric, nullable=True) mfe_5d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
mae_5d: Mapped[float | None] = mapped_column(Numeric, nullable=True) mae_5d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
mfe_10d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
mae_10d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
mfe_20d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
mae_20d: Mapped[float | None] = mapped_column(Numeric, nullable=True)
bars_to_mfe_3d: Mapped[int | None] = mapped_column(Integer, nullable=True) bars_to_mfe_3d: Mapped[int | None] = mapped_column(Integer, nullable=True)
bars_to_mae_3d: Mapped[int | None] = mapped_column(Integer, nullable=True) bars_to_mae_3d: Mapped[int | None] = mapped_column(Integer, nullable=True)
days_to_peak_close_5d: Mapped[int | None] = mapped_column(Integer, nullable=True) days_to_peak_close_5d: Mapped[int | None] = mapped_column(Integer, nullable=True)

@ -13,9 +13,9 @@ from libs.labeler.reaction_date import compute_reaction_date
logger = get_logger(__name__) logger = get_logger(__name__)
LABEL_VERSION = "label-1.0.0" LABEL_VERSION = "label-2.0.0"
_LOOK_AHEAD_DAYS = 7 # fetch this many trading days of bars for label computation _LOOK_AHEAD_DAYS = 25 # fetch enough trading days for 20D label computation
_R_FACTOR = 0.01 # 1R = 1% move (used for hit_pos/neg_1r labels) _R_FACTOR = 0.01 # 1R = 1% move (used for hit_pos/neg_1r labels)
@ -202,7 +202,7 @@ async def generate_labels(
forward_bars = bars[1:] # Day 1+ after entry forward_bars = bars[1:] # Day 1+ after entry
label_status = "ok" label_status = "ok"
if len(forward_bars) < 5: if len(forward_bars) < 20:
label_status = "truncated" label_status = "truncated"
# 1D return # 1D return
@ -212,6 +212,10 @@ async def generate_labels(
lbl_3d = _compute_labels_from_bars(entry_price, forward_bars, 3) lbl_3d = _compute_labels_from_bars(entry_price, forward_bars, 3)
# 5D labels # 5D labels
lbl_5d = _compute_labels_from_bars(entry_price, forward_bars, 5) lbl_5d = _compute_labels_from_bars(entry_price, forward_bars, 5)
# 10D labels
lbl_10d = _compute_labels_from_bars(entry_price, forward_bars, 10)
# 20D labels
lbl_20d = _compute_labels_from_bars(entry_price, forward_bars, 20)
return EventLabel( return EventLabel(
event_id=event.event_id, event_id=event.event_id,
@ -222,6 +226,8 @@ async def generate_labels(
fwd_return_1d=fwd_1d, fwd_return_1d=fwd_1d,
fwd_return_3d=lbl_3d.get("fwd_return"), fwd_return_3d=lbl_3d.get("fwd_return"),
fwd_return_5d=lbl_5d.get("fwd_return"), fwd_return_5d=lbl_5d.get("fwd_return"),
fwd_return_10d=lbl_10d.get("fwd_return"),
fwd_return_20d=lbl_20d.get("fwd_return"),
hit_pos_1r_within_3d=lbl_3d.get("hit_pos_1r"), hit_pos_1r_within_3d=lbl_3d.get("hit_pos_1r"),
hit_neg_1r_within_3d=lbl_3d.get("hit_neg_1r"), hit_neg_1r_within_3d=lbl_3d.get("hit_neg_1r"),
close_up_after_3d=lbl_3d.get("close_up"), close_up_after_3d=lbl_3d.get("close_up"),
@ -230,8 +236,12 @@ async def generate_labels(
mae_3d=lbl_3d.get("mae"), mae_3d=lbl_3d.get("mae"),
mfe_5d=lbl_5d.get("mfe"), mfe_5d=lbl_5d.get("mfe"),
mae_5d=lbl_5d.get("mae"), mae_5d=lbl_5d.get("mae"),
mfe_10d=lbl_10d.get("mfe"),
mae_10d=lbl_10d.get("mae"),
mfe_20d=lbl_20d.get("mfe"),
mae_20d=lbl_20d.get("mae"),
bars_to_mfe_3d=lbl_3d.get("bars_to_mfe"), bars_to_mfe_3d=lbl_3d.get("bars_to_mfe"),
bars_to_mae_3d=None, # symmetrically bars to MAE (min low) - optional bars_to_mae_3d=None,
days_to_peak_close_5d=lbl_5d.get("days_to_peak_close"), days_to_peak_close_5d=lbl_5d.get("days_to_peak_close"),
label_status=label_status, label_status=label_status,
invalid_event_for_labeling=False, invalid_event_for_labeling=False,

@ -81,9 +81,21 @@ class TestComputeStopPrice:
per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
max_positions=10, max_positions_per_sector=3 max_positions=10, max_positions_per_sector=3
)) ))
# 1.5 * ATR below price # 1.5 * ATR below price (default multiplier)
assert stop == pytest.approx(100.0 - 1.5 * 2.0) assert stop == pytest.approx(100.0 - 1.5 * 2.0)
def test_atr_stop_custom_multiplier(self):
from libs.backtest.allocator import compute_stop_price
c = _make_candidate(entry_price_est=100.0, atr_14=2.0)
stop = compute_stop_price(c, RiskConfig(
per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
max_positions=10, max_positions_per_sector=3,
stop_atr_multiplier=2.0,
))
# 2.0 * ATR below price
assert stop == pytest.approx(100.0 - 2.0 * 2.0)
def test_fallback_stop_when_no_atr(self): def test_fallback_stop_when_no_atr(self):
from libs.backtest.allocator import compute_stop_price from libs.backtest.allocator import compute_stop_price
@ -216,6 +228,184 @@ class TestRunEntryGates:
assert result == "cooldown" assert result == "cooldown"
class TestMacroRegimeGate:
"""Macro regime filter gate tests."""
def test_blocks_when_spy_below_sma(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
macro = {"spy_close": 490.0, "spy_sma_20": 500.0} # SPY below SMA
result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result == "macro_regime_unfavorable"
def test_passes_when_spy_above_sma(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
macro = {"spy_close": 510.0, "spy_sma_20": 500.0} # SPY above SMA
result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result is None
def test_passes_when_spy_equals_sma(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
macro = {"spy_close": 500.0, "spy_sma_20": 500.0} # Equal — not unfavorable
result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result is None
def test_disabled_by_default(self):
from libs.backtest.allocator import run_entry_gates
c = _make_candidate()
ps = _make_portfolio_state()
cfg = _make_config()
# macro_regime_enabled defaults to False
macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result is None # Gate is disabled, should pass
def test_passes_when_no_macro_data(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
result = run_entry_gates(c, ps, [], cfg, macro_data=None)
assert result is None # No data available, don't block
def test_passes_when_sma_not_computed(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
macro = {"spy_close": 490.0, "spy_sma_20": None} # SMA not yet computed
result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
assert result is None # Can't evaluate, don't block
def test_build_planned_order_with_macro(self):
from libs.backtest.allocator import build_planned_order
c = _make_candidate()
ps = _make_portfolio_state()
cfg = _make_config()
cfg.risk.macro_regime_enabled = True
macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
order = build_planned_order(c, ps, [], cfg, macro_data=macro)
assert order.skip_reason == "macro_regime_unfavorable"
class TestComputeTargetPrice:
def test_fixed_r_default(self):
from libs.backtest.allocator import compute_target_price
target = compute_target_price(100.0, 95.0, 2.0)
assert target == pytest.approx(110.0) # 100 + (100-95)*2
def test_atr_multiple_model(self):
from libs.backtest.allocator import compute_target_price
target = compute_target_price(
100.0, 95.0, 2.0,
target_model="atr_multiple",
target_atr_multiplier=1.5,
atr_14=3.0,
)
assert target == pytest.approx(104.5) # 100 + 3.0*1.5
def test_atr_multiple_falls_back_when_no_atr(self):
from libs.backtest.allocator import compute_target_price
target = compute_target_price(
100.0, 95.0, 2.0,
target_model="atr_multiple",
atr_14=None,
)
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:
def test_bullish_only_blocks_bearish(self):
from libs.backtest.allocator import run_entry_gates
from libs.backtest.domain import EventTypeProfile
c = _make_candidate(
event_type="earnings_release",
features={"reaction_day_return": -0.02, "eps_growth_qoq": 0.10},
)
ps = _make_portfolio_state()
cfg = _make_config()
cfg.event_type_profiles = {
"earnings_release": EventTypeProfile(direction_filter="bullish_only"),
}
result = run_entry_gates(c, ps, [], cfg)
assert result == "direction_filter_bearish"
def test_bullish_only_passes_positive(self):
from libs.backtest.allocator import run_entry_gates
from libs.backtest.domain import EventTypeProfile
c = _make_candidate(
event_type="earnings_release",
features={"reaction_day_return": 0.02, "eps_growth_qoq": 0.10},
)
ps = _make_portfolio_state()
cfg = _make_config()
cfg.event_type_profiles = {
"earnings_release": EventTypeProfile(direction_filter="bullish_only"),
}
result = run_entry_gates(c, ps, [], cfg)
assert result is None
class TestBuildPlannedOrder: class TestBuildPlannedOrder:
def test_valid_order(self): def test_valid_order(self):
from libs.backtest.allocator import build_planned_order from libs.backtest.allocator import build_planned_order
@ -236,3 +426,15 @@ class TestBuildPlannedOrder:
order = build_planned_order(c, ps, [], _make_config()) order = build_planned_order(c, ps, [], _make_config())
assert order.skip_reason == "kill_switch_drawdown" assert order.skip_reason == "kill_switch_drawdown"
assert order.shares == 0 assert order.shares == 0
def test_atr_target_model_in_order(self):
from libs.backtest.allocator import build_planned_order
c = _make_candidate(entry_price_est=100.0, atr_14=3.0)
ps = _make_portfolio_state()
cfg = _make_config()
cfg.execution.target_model = "atr_multiple"
cfg.execution.target_atr_multiplier = 1.5
order = build_planned_order(c, ps, [], cfg)
assert order.skip_reason is None
assert order.target_price == pytest.approx(104.5) # 100 + 3.0*1.5

@ -11,6 +11,7 @@ from libs.backtest.domain import (
BacktestConfig, BacktestConfig,
Candidate, Candidate,
DailyPortfolioState, DailyPortfolioState,
EventTypeProfile,
ExitReason, ExitReason,
ExecutionConfig, ExecutionConfig,
ExperimentManifest, ExperimentManifest,
@ -221,6 +222,17 @@ class TestConfigModels:
assert isinstance(cfg.risk, RiskConfig) assert isinstance(cfg.risk, RiskConfig)
assert isinstance(cfg.execution, ExecutionConfig) assert isinstance(cfg.execution, ExecutionConfig)
def test_execution_config_target_model(self):
e = ExecutionConfig(target_model="atr_multiple", target_atr_multiplier=2.0)
assert e.target_model == "atr_multiple"
assert e.target_atr_multiplier == 2.0
def test_execution_config_defaults(self):
e = ExecutionConfig()
assert e.target_model == "fixed_r"
assert e.target_atr_multiplier == 1.5
assert e.target_1_fraction is None
def test_experiment_manifest(self): def test_experiment_manifest(self):
m = ExperimentManifest( m = ExperimentManifest(
experiment_name="test_exp", experiment_name="test_exp",
@ -230,3 +242,52 @@ class TestConfigModels:
) )
assert m.experiment_name == "test_exp" assert m.experiment_name == "test_exp"
assert m.splits == [] assert m.splits == []
class TestEventTypeProfile:
def test_defaults(self):
p = EventTypeProfile()
assert p.enabled is True
assert p.score_threshold_override is None
assert p.direction_filter == "any"
def test_disabled(self):
p = EventTypeProfile(enabled=False)
assert p.enabled is False
def test_overrides(self):
p = EventTypeProfile(
max_holding_days_override=15,
stop_atr_multiplier_override=2.5,
target_atr_multiplier_override=1.0,
)
assert p.max_holding_days_override == 15
def test_backtest_config_with_profiles(self):
cfg = BacktestConfig(
strategy_name="test",
dataset_snapshot_id="snap_001",
event_type_profiles={
"earnings_release": EventTypeProfile(max_holding_days_override=15),
"management_change": EventTypeProfile(enabled=False),
},
)
p = cfg.get_event_profile("earnings_release")
assert p is not None
assert p.max_holding_days_override == 15
assert cfg.get_event_profile("unknown") is None
def test_backtest_config_default_empty_profiles(self):
cfg = BacktestConfig(strategy_name="test", dataset_snapshot_id="snap_001")
assert cfg.event_type_profiles == {}
assert cfg.get_event_profile("anything") is None
class TestMetricsBundleBootstrap:
def test_bootstrap_cis_field(self):
m = MetricsBundle(bootstrap_cis={"win_rate_ci_95": (0.2, 0.6)})
assert m.bootstrap_cis["win_rate_ci_95"] == (0.2, 0.6)
def test_bootstrap_cis_default_empty(self):
m = MetricsBundle()
assert m.bootstrap_cis == {}

@ -233,6 +233,70 @@ class TestSimulateExit:
assert trade.net_pnl == pytest.approx(998.0) assert trade.net_pnl == pytest.approx(998.0)
class TestPartialExit:
def test_partial_exit_at_target(self):
"""When target_1_fraction < 1.0 and target is hit, partial exit occurs."""
from libs.backtest.execution import simulate_exit
pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
bar = _make_bar(low=102.0, high=115.0) # target hit
cfg = _make_exec_config(target_1_fraction=0.5)
trade = simulate_exit(pos, bar, cfg, _TOMORROW)
assert trade is not None
assert trade.exit_reason == ExitReason.TARGET
assert trade.shares == 50 # 50% of 100
assert pos.shares_open == 50 # remaining
assert pos.current_stop == pytest.approx(101.0) # breakeven
assert pos.status == PositionStatus.PARTIALLY_EXITED
def test_partial_exit_records_in_partial_fills(self):
from libs.backtest.execution import simulate_exit
pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
bar = _make_bar(low=102.0, high=115.0)
cfg = _make_exec_config(target_1_fraction=0.5)
simulate_exit(pos, bar, cfg, _TOMORROW)
assert len(pos.partial_fills) == 1
def test_second_target_hit_closes_remainder(self):
"""After partial exit, second target hit closes remaining shares fully."""
from libs.backtest.execution import simulate_exit
pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
pos.status = PositionStatus.PARTIALLY_EXITED # already partially exited
pos.shares_open = 50
bar = _make_bar(low=102.0, high=115.0)
cfg = _make_exec_config(target_1_fraction=0.5)
trade = simulate_exit(pos, bar, cfg, _TOMORROW)
# Should do full exit on second target hit (PARTIALLY_EXITED status)
assert trade is not None
assert trade.shares == 50 # remaining shares
def test_full_exit_when_fraction_is_1(self):
"""When target_1_fraction == 1.0, full exit as before."""
from libs.backtest.execution import simulate_exit
pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
bar = _make_bar(low=102.0, high=115.0)
cfg = _make_exec_config(target_1_fraction=1.0)
trade = simulate_exit(pos, bar, cfg, _TOMORROW)
assert trade is not None
assert trade.shares == 100
def test_stop_exit_ignores_partial_fraction(self):
"""Stop exits always close fully (partial only on TARGET)."""
from libs.backtest.execution import simulate_exit
pos = _make_open_position(entry_price=101.0, stop=95.0, target=130.0, shares=100)
bar = _make_bar(low=90.0, high=100.0) # stop hit
cfg = _make_exec_config(target_1_fraction=0.5)
trade = simulate_exit(pos, bar, cfg, _TOMORROW)
assert trade is not None
assert trade.exit_reason == ExitReason.STOP
assert trade.shares == 100 # full close
class TestUpdateTrailingStop: class TestUpdateTrailingStop:
def test_ratchets_up(self): def test_ratchets_up(self):
from libs.backtest.execution import update_trailing_stop from libs.backtest.execution import update_trailing_stop
@ -255,3 +319,71 @@ class TestUpdateTrailingStop:
pos.peak_price = 100.0 pos.peak_price = 100.0
update_trailing_stop(pos, _make_bar(high=115.0, low=100.0)) update_trailing_stop(pos, _make_bar(high=115.0, low=100.0))
assert pos.peak_price == pytest.approx(115.0) assert pos.peak_price == pytest.approx(115.0)
def test_pct_trailing_ratchets_up(self):
from libs.backtest.execution import update_trailing_stop
pos = _make_open_position(entry_price=100.0, stop=95.0)
pos.peak_price = 100.0
# Peak goes to 110, trail at 3% → stop = 110 * 0.97 = 106.7
update_trailing_stop(pos, _make_bar(high=110.0, low=105.0), trailing_model="pct_3")
assert pos.current_stop == pytest.approx(110.0 * 0.97)
assert pos.peak_price == pytest.approx(110.0)
def test_pct_trailing_never_moves_down(self):
from libs.backtest.execution import update_trailing_stop
pos = _make_open_position(entry_price=100.0, stop=98.0)
pos.peak_price = 100.0
# Peak stays at 100, trail at 3% → stop = 97. But current_stop=98 > 97, so no change
update_trailing_stop(pos, _make_bar(high=99.0, low=96.0), trailing_model="pct_3")
assert pos.current_stop == pytest.approx(98.0)
def test_pct_5_trailing(self):
from libs.backtest.execution import update_trailing_stop
pos = _make_open_position(entry_price=100.0, stop=90.0)
pos.peak_price = 100.0
# Peak goes to 120, trail at 5% → stop = 120 * 0.95 = 114.0
update_trailing_stop(pos, _make_bar(high=120.0, low=115.0), trailing_model="pct_5")
assert pos.current_stop == pytest.approx(114.0)
def test_warmup_skips_trailing(self):
"""Trailing stop should not activate during warmup period."""
from libs.backtest.execution import update_trailing_stop
pos = _make_open_position(entry_price=100.0, stop=95.0)
pos.peak_price = 100.0
pos.days_held = 1 # below warmup
# Bar low is 98 which would normally ratchet stop up
update_trailing_stop(pos, _make_bar(high=105.0, low=98.0), warmup_days=2)
assert pos.current_stop == pytest.approx(95.0) # unchanged
assert pos.peak_price == pytest.approx(105.0) # peak still tracked
def test_warmup_activates_after_period(self):
"""Trailing stop activates once warmup period is reached."""
from libs.backtest.execution import update_trailing_stop
pos = _make_open_position(entry_price=100.0, stop=95.0)
pos.peak_price = 100.0
pos.days_held = 2 # equals warmup → active
update_trailing_stop(pos, _make_bar(high=105.0, low=98.0), warmup_days=2)
assert pos.current_stop == pytest.approx(98.0) # ratcheted up
def test_pct_warmup_combined(self):
"""pct_3 trailing with warmup: no trailing during warmup, then activates."""
from libs.backtest.execution import update_trailing_stop
pos = _make_open_position(entry_price=100.0, stop=94.0)
pos.peak_price = 100.0
pos.days_held = 1
# Day 1: warmup, peak tracks but stop unchanged
update_trailing_stop(pos, _make_bar(high=108.0, low=102.0), trailing_model="pct_3", warmup_days=2)
assert pos.current_stop == pytest.approx(94.0)
assert pos.peak_price == pytest.approx(108.0)
# Day 2: warmup over, trailing activates with accumulated peak
pos.days_held = 2
update_trailing_stop(pos, _make_bar(high=110.0, low=106.0), trailing_model="pct_3", warmup_days=2)
# peak=110, trail=110*0.97=106.7
assert pos.current_stop == pytest.approx(110.0 * 0.97)

@ -225,3 +225,50 @@ class TestBuildMetricsBundle:
m = build_metrics_bundle([], []) m = build_metrics_bundle([], [])
assert m.trade_count == 0 assert m.trade_count == 0
assert m.win_rate is None assert m.win_rate is None
def test_bootstrap_cis_included(self):
from libs.backtest.metrics import build_metrics_bundle
# Need ≥5 trades for bootstrap
trades = [
_make_trade(100, ExitReason.TARGET, r_multiple=2.0, exit_date=dt.date(2026, 1, 10 + i))
for i in range(3)
] + [
_make_trade(-50, ExitReason.STOP, r_multiple=-1.0, exit_date=dt.date(2026, 1, 20 + i))
for i in range(3)
]
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000),
_make_equity_state(dt.date(2026, 1, 30), 105_000),
]
m = build_metrics_bundle(trades, curve)
assert "win_rate_ci_95" in m.bootstrap_cis
ci = m.bootstrap_cis["win_rate_ci_95"]
assert ci is not None
assert ci[0] <= ci[1] # lower ≤ upper
class TestBootstrapCI:
def test_basic_ci(self):
from libs.backtest.metrics import bootstrap_ci, compute_win_rate
trades = [_make_trade(100)] * 4 + [_make_trade(-50)] * 4
ci = bootstrap_ci(trades, compute_win_rate, n_iterations=500, seed=42)
assert ci is not None
lo, hi = ci
assert 0.0 <= lo <= hi <= 1.0
def test_too_few_trades(self):
from libs.backtest.metrics import bootstrap_ci, compute_win_rate
trades = [_make_trade(100)] * 3
ci = bootstrap_ci(trades, compute_win_rate)
assert ci is None
def test_deterministic_with_seed(self):
from libs.backtest.metrics import bootstrap_ci, compute_win_rate
trades = [_make_trade(100)] * 5 + [_make_trade(-50)] * 5
ci1 = bootstrap_ci(trades, compute_win_rate, seed=42)
ci2 = bootstrap_ci(trades, compute_win_rate, seed=42)
assert ci1 == ci2

@ -5,6 +5,7 @@ import pytest
from libs.backtest.scoring import ( from libs.backtest.scoring import (
_close_strength_score, _close_strength_score,
_earnings_surprise_score,
_event_quality_score, _event_quality_score,
_gap_score, _gap_score,
_reaction_score, _reaction_score,
@ -127,7 +128,7 @@ class TestComputeEntryScore:
def test_all_missing_returns_neutral(self): def test_all_missing_returns_neutral(self):
"""All features missing → all defaults at 0.5 → composite 0.5.""" """All features missing → all defaults at 0.5 → composite 0.5."""
score = compute_entry_score({}) score = compute_entry_score({})
assert score == pytest.approx(0.5, abs=0.01) 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."""
@ -251,6 +252,34 @@ class TestComputeEntryScore:
assert low_risk > high_risk assert low_risk > high_risk
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: class TestEventQualityScore:
"""Event quality scoring.""" """Event quality scoring."""

@ -6,7 +6,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from libs.backtest.domain import SignalConfig, UniverseConfig from libs.backtest.domain import EventTypeProfile, SignalConfig, UniverseConfig
_UTC = ZoneInfo("UTC") _UTC = ZoneInfo("UTC")
@ -196,6 +196,45 @@ class TestFilterCandidates:
assert len(truncated) == 3 assert len(truncated) == 3
class TestFilterByEventType:
def test_disabled_event_type_filtered(self):
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="management_change"),
]
candidates = [build_candidate(r) for r in rows if build_candidate(r)]
profiles = {
"management_change": EventTypeProfile(enabled=False),
}
filtered = filter_by_event_type(candidates, profiles)
assert len(filtered) == 1
assert filtered[0].symbol == "A"
def test_per_type_score_threshold(self):
from libs.backtest.selector import build_candidate, filter_by_event_type
rows = [
_make_raw_row(symbol="A", event_type="earnings_release", score=0.55),
_make_raw_row(symbol="B", event_type="earnings_release", score=0.75),
]
candidates = [build_candidate(r) for r in rows if build_candidate(r)]
profiles = {
"earnings_release": EventTypeProfile(score_threshold_override=0.6),
}
filtered = filter_by_event_type(candidates, profiles)
assert len(filtered) == 1
assert filtered[0].symbol == "B"
def test_no_profiles_passthrough(self):
from libs.backtest.selector import build_candidate, filter_by_event_type
rows = [_make_raw_row(symbol="A")]
candidates = [build_candidate(r) for r in rows if build_candidate(r)]
assert filter_by_event_type(candidates, {}) == candidates
class TestSelectCandidates: class TestSelectCandidates:
def test_full_pipeline(self): def test_full_pipeline(self):
from libs.backtest.selector import select_candidates from libs.backtest.selector import select_candidates
@ -214,3 +253,17 @@ class TestSelectCandidates:
assert "B" not in symbols # below threshold assert "B" not in symbols # below threshold
assert "C" not in symbols # low ADV assert "C" not in symbols # low ADV
assert "D" not in symbols # below min_price assert "D" not in symbols # below min_price
def test_pipeline_with_event_type_profiles(self):
from libs.backtest.selector import select_candidates
rows = [
_make_raw_row(symbol="A", score=0.9, avg_dollar_volume=5e6, entry_price=100.0, event_type="earnings_release"),
_make_raw_row(symbol="B", score=0.7, avg_dollar_volume=5e6, entry_price=100.0, event_type="management_change"),
]
u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000)
s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10)
profiles = {"management_change": EventTypeProfile(enabled=False)}
result = select_candidates(rows, u, s, event_type_profiles=profiles)
assert len(result) == 1
assert result[0].symbol == "A"

Loading…
Cancel
Save