From cdf6ae349358d0325a87e22643cfa4141349ee8e Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Fri, 13 Mar 2026 09:35:04 -0700 Subject: [PATCH] feat: Phase 5 fundamental strategy improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/backtester/run.py | 22 +- configs/backtest/defaults.json | 32 ++- configs/experiments/expanded_scored_v1.json | 10 +- configs/symbols.yaml | 86 ++++++++ libs/backtest/allocator.py | 72 ++++++- libs/backtest/domain.py | 28 ++- libs/backtest/execution.py | 120 ++++++++++- libs/backtest/metrics.py | 70 ++++++ libs/backtest/scoring.py | 55 ++++- libs/backtest/selector.py | 32 ++- libs/backtest/snapshot_store.py | 58 +++++ .../versions/0003_phase5_extended_labels.py | 39 ++++ libs/db/models.py | 6 + libs/labeler/label_generator.py | 18 +- tests/unit/backtest/test_allocator.py | 204 +++++++++++++++++- tests/unit/backtest/test_domain.py | 61 ++++++ tests/unit/backtest/test_execution.py | 132 ++++++++++++ tests/unit/backtest/test_metrics.py | 47 ++++ tests/unit/backtest/test_scoring.py | 31 ++- tests/unit/backtest/test_selector.py | 55 ++++- 20 files changed, 1139 insertions(+), 39 deletions(-) create mode 100644 libs/db/migrations/versions/0003_phase5_extended_labels.py diff --git a/apps/backtester/run.py b/apps/backtester/run.py index 762bf27..9cd68e4 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -206,9 +206,21 @@ class BacktestRunner: # Update trailing stop if configured 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: newly_closed.append(trade) else: @@ -256,9 +268,12 @@ class BacktestRunner: self._total_candidates_seen += len(raw_rows) portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized) 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: plan = build_planned_order( candidate=candidate, @@ -266,6 +281,7 @@ class BacktestRunner: open_positions=self._open_positions, config=self.config, cooldown_remaining=self._cooldown_remaining, + macro_data=macro_data, ) if plan.skip_reason is not None: diff --git a/configs/backtest/defaults.json b/configs/backtest/defaults.json index 6cfcaf6..fcb818f 100644 --- a/configs/backtest/defaults.json +++ b/configs/backtest/defaults.json @@ -21,7 +21,10 @@ "max_position_value_pct": 0.10, "max_adv_fraction": 0.01, "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": { "entry_fill_model": "next_open", @@ -29,8 +32,12 @@ "slippage_bps_base": 10.0, "commission_per_share": 0.005, "same_bar_priority": "stop_first_conservative", + "target_model": "atr_multiple", "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 }, "reporting": { @@ -39,5 +46,26 @@ "write_metrics_summary": true, "generate_plots": false, "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 + } } } diff --git a/configs/experiments/expanded_scored_v1.json b/configs/experiments/expanded_scored_v1.json index 106d939..cdb7c16 100644 --- a/configs/experiments/expanded_scored_v1.json +++ b/configs/experiments/expanded_scored_v1.json @@ -12,10 +12,16 @@ "per_trade_risk_pct": 0.01, "max_daily_new_risk_pct": 0.05, "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": { - "max_holding_days": 5 + "max_holding_days": 5, + "target_1_r": 1.5, + "trailing_model": "pct_3", + "trailing_warmup_days": 2 } }, "splits": [], diff --git a/configs/symbols.yaml b/configs/symbols.yaml index 4f78407..c7d6a28 100644 --- a/configs/symbols.yaml +++ b/configs/symbols.yaml @@ -1,4 +1,5 @@ symbols: + # --- Mega-cap tech (original 15) --- - AAPL - MSFT - GOOGL @@ -14,3 +15,88 @@ symbols: - DDOG - ZS - 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 diff --git a/libs/backtest/allocator.py b/libs/backtest/allocator.py index 863a497..50ad026 100644 --- a/libs/backtest/allocator.py +++ b/libs/backtest/allocator.py @@ -8,6 +8,7 @@ from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, + EventTypeProfile, OpenPosition, PlannedOrder, RiskConfig, @@ -28,7 +29,7 @@ def compute_stop_price(candidate: Candidate, config: RiskConfig) -> float: """ price = candidate.entry_price_est 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: # Fallback: 2% of price stop_distance = price * 0.02 @@ -39,8 +40,21 @@ def compute_target_price( entry_price_est: float, stop_price: float, target_r: float = 2.0, + *, + target_model: str = "fixed_r", + target_atr_multiplier: float = 1.5, + atr_14: float | None = None, ) -> 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 if risk <= 0: return entry_price_est * 1.10 # 10% default target @@ -76,10 +90,12 @@ def run_entry_gates( open_positions: list[OpenPosition], config: BacktestConfig, cooldown_remaining: int = 0, + macro_data: dict[str, Any] | None = 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): + 0. Macro regime (SPY below SMA — bearish market) 1. Kill switch (drawdown >= threshold) 2. Max total positions 3. Duplicate symbol already open @@ -88,6 +104,13 @@ def run_entry_gates( 6. Cash available (estimated position cost) 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 if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT: return "kill_switch_drawdown" @@ -127,6 +150,19 @@ def run_entry_gates( if cooldown_remaining > 0: 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 @@ -136,15 +172,39 @@ def build_planned_order( open_positions: list[OpenPosition], config: BacktestConfig, cooldown_remaining: int = 0, + macro_data: dict[str, Any] | None = None, ) -> PlannedOrder: """Build a PlannedOrder. skip_reason is non-None if any gate rejected it.""" 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_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 risk_dollars = 0.0 diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index 435fbdb..8750f3b 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -154,6 +154,9 @@ class MetricsBundle(BaseModel): target_exit_rate: float | None = None 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) @@ -183,6 +186,9 @@ class RiskConfig(BaseModel): max_adv_fraction: float | None = None # max fraction of avg daily volume cooldown_after_loss_streak: int = 0 # consecutive losses to trigger cooldown 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): @@ -192,12 +198,25 @@ class ExecutionConfig(BaseModel): commission_per_share: float = 0.005 same_bar_priority: str = "stop_first_conservative" stop_model: str | None = None - target_1_r: float | None = None # R-multiple for first target - target_1_fraction: float | None = None # fraction to exit at target_1 + target_model: str = "fixed_r" # "fixed_r" or "atr_multiple" + 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_warmup_days: int = 0 # days after entry before trailing activates 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): write_trade_blotter: bool = True write_equity_curve: bool = True @@ -214,6 +233,11 @@ class BacktestConfig(BaseModel): risk: RiskConfig = Field(default_factory=RiskConfig) execution: ExecutionConfig = Field(default_factory=ExecutionConfig) 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) # --------------------------------------------------------------------------- diff --git a/libs/backtest/execution.py b/libs/backtest/execution.py index 5c858ff..7d49c4e 100644 --- a/libs/backtest/execution.py +++ b/libs/backtest/execution.py @@ -112,11 +112,15 @@ def simulate_exit( Handles: - 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) - Time exit (days_held >= max_holding_days) - 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. """ if bar is None: @@ -157,6 +161,32 @@ def simulate_exit( if exit_reason is None or exit_fill_price is 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) @@ -194,24 +224,96 @@ def simulate_missing_bar_exit( # --------------------------------------------------------------------------- -def update_trailing_stop(position: OpenPosition, bar: dict[str, Any]) -> None: - """Ratchet stop up to bar low (never down). Mutates position in place.""" - bar_low = bar.get("low") - if bar_low is not None: - new_stop = max(position.current_stop, float(bar_low)) - position.current_stop = new_stop - - # Track peak price +def update_trailing_stop( + 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") + if bar_low is not None: + new_stop = max(position.current_stop, float(bar_low)) + position.current_stop = new_stop + elif trailing_model.startswith("pct_"): + try: + trail_pct = float(trailing_model.split("_")[1]) / 100.0 + except (IndexError, ValueError): + trail_pct = 0.03 + trail_stop = position.peak_price * (1.0 - trail_pct) + position.current_stop = max(position.current_stop, trail_stop) + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- +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( position: OpenPosition, exit_price: float, diff --git a/libs/backtest/metrics.py b/libs/backtest/metrics.py index b5456a1..7c307e5 100644 --- a/libs/backtest/metrics.py +++ b/libs/backtest/metrics.py @@ -7,6 +7,7 @@ from __future__ import annotations import datetime as dt import math +import random import statistics from collections import defaultdict 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 # --------------------------------------------------------------------------- @@ -314,6 +379,9 @@ def build_metrics_bundle( ann_ret = compute_annualized_return_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( # Trade trade_count=len(trades), @@ -342,4 +410,6 @@ def build_metrics_bundle( stop_exit_rate=compute_stop_exit_rate(trades), target_exit_rate=compute_target_exit_rate(trades), score_bucket_hit_rate=compute_score_bucket_hit_rate(trades, candidate_map or {}), + # Bootstrap CIs + bootstrap_cis=cis, ) diff --git a/libs/backtest/scoring.py b/libs/backtest/scoring.py index dbf3168..2ac4166 100644 --- a/libs/backtest/scoring.py +++ b/libs/backtest/scoring.py @@ -28,14 +28,15 @@ def compute_entry_score(row: dict[str, Any]) -> float: """Compute composite entry score from market + event features. Components and weights: - Market (75%): - 1. Reaction quality (25%) — moderate positive return is ideal - 2. Close strength (25%) — close near high = buyers won the day + Market (65%): + 1. Reaction quality (20%) — moderate positive return is ideal + 2. Close strength (20%) — close near high = buyers won the day 3. Volume conviction (15%) — above-average but not exhaustion 4. Gap quality (10%) — small positive gap = orderly strength - Event (25%): + Event (35%): 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]. """ @@ -47,14 +48,16 @@ def compute_entry_score(row: dict[str, Any]) -> float: # Event components (gracefully handle missing features) event = _event_quality_score(row) + sue = _earnings_surprise_score(row) risk = _risk_penalty_score(row) raw = ( - reaction * 0.25 - + close * 0.25 + reaction * 0.20 + + close * 0.20 + volume * 0.15 + gap * 0.10 + event * 0.15 + + sue * 0.10 + risk * 0.10 ) return max(0.0, min(1.0, raw)) @@ -179,6 +182,44 @@ def _gap_score(row: dict[str, Any]) -> float: 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 # --------------------------------------------------------------------------- diff --git a/libs/backtest/selector.py b/libs/backtest/selector.py index 4a256d1..3df8fae 100644 --- a/libs/backtest/selector.py +++ b/libs/backtest/selector.py @@ -5,7 +5,7 @@ import datetime as dt from typing import Any 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 logger = get_logger(__name__) @@ -157,10 +157,38 @@ def truncate_candidates( 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( raw_rows: list[dict[str, Any]], universe_config: UniverseConfig, signal_config: SignalConfig, + event_type_profiles: dict[str, EventTypeProfile] | None = None, ) -> list[Candidate]: """Full selection pipeline: build → filter → rank → truncate.""" candidates = [] @@ -171,6 +199,8 @@ def select_candidates( candidates = filter_by_universe(candidates, universe_config) 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 = truncate_candidates(candidates, signal_config.max_candidates_per_day) return candidates diff --git a/libs/backtest/snapshot_store.py b/libs/backtest/snapshot_store.py index 27cfc59..d82bd79 100644 --- a/libs/backtest/snapshot_store.py +++ b/libs/backtest/snapshot_store.py @@ -141,6 +141,11 @@ class SnapshotStore: sectors = await cls._fetch_sectors(unique_symbols, oracle_url) 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 candidates_by_exec_date: dict[dt.date, list[dict[str, Any]]] = {} for row in row_list: @@ -369,6 +374,59 @@ class SnapshotStore: logger.warning("snapshot_store_macro_fetch_failed", error=str(exc)) 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 def _compute_date_range( rows: list[dict[str, Any]], diff --git a/libs/db/migrations/versions/0003_phase5_extended_labels.py b/libs/db/migrations/versions/0003_phase5_extended_labels.py new file mode 100644 index 0000000..f5df38d --- /dev/null +++ b/libs/db/migrations/versions/0003_phase5_extended_labels.py @@ -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") diff --git a/libs/db/models.py b/libs/db/models.py index 0ddb3bb..207653c 100644 --- a/libs/db/models.py +++ b/libs/db/models.py @@ -429,6 +429,8 @@ class EventLabel(Base): fwd_return_1d: 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_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_neg_1r_within_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) mfe_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_mae_3d: Mapped[int | None] = mapped_column(Integer, nullable=True) days_to_peak_close_5d: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/libs/labeler/label_generator.py b/libs/labeler/label_generator.py index ddab5b8..bd8279b 100644 --- a/libs/labeler/label_generator.py +++ b/libs/labeler/label_generator.py @@ -13,9 +13,9 @@ from libs.labeler.reaction_date import compute_reaction_date 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) @@ -202,7 +202,7 @@ async def generate_labels( forward_bars = bars[1:] # Day 1+ after entry label_status = "ok" - if len(forward_bars) < 5: + if len(forward_bars) < 20: label_status = "truncated" # 1D return @@ -212,6 +212,10 @@ async def generate_labels( lbl_3d = _compute_labels_from_bars(entry_price, forward_bars, 3) # 5D labels 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( event_id=event.event_id, @@ -222,6 +226,8 @@ async def generate_labels( fwd_return_1d=fwd_1d, fwd_return_3d=lbl_3d.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_neg_1r_within_3d=lbl_3d.get("hit_neg_1r"), close_up_after_3d=lbl_3d.get("close_up"), @@ -230,8 +236,12 @@ async def generate_labels( mae_3d=lbl_3d.get("mae"), mfe_5d=lbl_5d.get("mfe"), 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_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"), label_status=label_status, invalid_event_for_labeling=False, diff --git a/tests/unit/backtest/test_allocator.py b/tests/unit/backtest/test_allocator.py index 732ec30..27c5524 100644 --- a/tests/unit/backtest/test_allocator.py +++ b/tests/unit/backtest/test_allocator.py @@ -81,9 +81,21 @@ class TestComputeStopPrice: per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, 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) + 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): from libs.backtest.allocator import compute_stop_price @@ -216,6 +228,184 @@ class TestRunEntryGates: 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: def test_valid_order(self): from libs.backtest.allocator import build_planned_order @@ -236,3 +426,15 @@ class TestBuildPlannedOrder: order = build_planned_order(c, ps, [], _make_config()) assert order.skip_reason == "kill_switch_drawdown" 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 diff --git a/tests/unit/backtest/test_domain.py b/tests/unit/backtest/test_domain.py index d0fb13a..b0577eb 100644 --- a/tests/unit/backtest/test_domain.py +++ b/tests/unit/backtest/test_domain.py @@ -11,6 +11,7 @@ from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, + EventTypeProfile, ExitReason, ExecutionConfig, ExperimentManifest, @@ -221,6 +222,17 @@ class TestConfigModels: assert isinstance(cfg.risk, RiskConfig) 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): m = ExperimentManifest( experiment_name="test_exp", @@ -230,3 +242,52 @@ class TestConfigModels: ) assert m.experiment_name == "test_exp" 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 == {} diff --git a/tests/unit/backtest/test_execution.py b/tests/unit/backtest/test_execution.py index c452dbf..f84cbe3 100644 --- a/tests/unit/backtest/test_execution.py +++ b/tests/unit/backtest/test_execution.py @@ -233,6 +233,70 @@ class TestSimulateExit: 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: def test_ratchets_up(self): from libs.backtest.execution import update_trailing_stop @@ -255,3 +319,71 @@ class TestUpdateTrailingStop: pos.peak_price = 100.0 update_trailing_stop(pos, _make_bar(high=115.0, low=100.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) diff --git a/tests/unit/backtest/test_metrics.py b/tests/unit/backtest/test_metrics.py index cf40042..056b52e 100644 --- a/tests/unit/backtest/test_metrics.py +++ b/tests/unit/backtest/test_metrics.py @@ -225,3 +225,50 @@ class TestBuildMetricsBundle: m = build_metrics_bundle([], []) assert m.trade_count == 0 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 diff --git a/tests/unit/backtest/test_scoring.py b/tests/unit/backtest/test_scoring.py index 0cd9b9a..8944a16 100644 --- a/tests/unit/backtest/test_scoring.py +++ b/tests/unit/backtest/test_scoring.py @@ -5,6 +5,7 @@ import pytest from libs.backtest.scoring import ( _close_strength_score, + _earnings_surprise_score, _event_quality_score, _gap_score, _reaction_score, @@ -127,7 +128,7 @@ class TestComputeEntryScore: 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) + assert score == pytest.approx(0.5, abs=0.02) def test_ideal_setup_scores_high(self): """Moderate positive return + close near high + healthy volume.""" @@ -251,6 +252,34 @@ class TestComputeEntryScore: 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: """Event quality scoring.""" diff --git a/tests/unit/backtest/test_selector.py b/tests/unit/backtest/test_selector.py index 3afc199..c856186 100644 --- a/tests/unit/backtest/test_selector.py +++ b/tests/unit/backtest/test_selector.py @@ -6,7 +6,7 @@ from zoneinfo import ZoneInfo import pytest -from libs.backtest.domain import SignalConfig, UniverseConfig +from libs.backtest.domain import EventTypeProfile, SignalConfig, UniverseConfig _UTC = ZoneInfo("UTC") @@ -196,6 +196,45 @@ class TestFilterCandidates: 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: def test_full_pipeline(self): from libs.backtest.selector import select_candidates @@ -214,3 +253,17 @@ class TestSelectCandidates: assert "B" not in symbols # below threshold assert "C" not in symbols # low ADV 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"