"""Unit tests for the EarningsRunup pre-event drift engine.""" from __future__ import annotations import datetime as dt from typing import Any import pytest from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig from libs.backtest.earnings_calendar import ( EarningsCalendarEntry, PointInTimeEarningsCalendar, ) from libs.backtest.earnings_runup import ( EARNINGS_RUNUP_EVENT_TYPE, EarningsRunupTriggerInputs, _PitCalendarUpcomingEarningsAdapter, _SnapshotStoreBarAdapter, build_earnings_runup_candidates, evaluate_trigger, ) # --------------------------------------------------------------------------- # Fakes # --------------------------------------------------------------------------- class _FakeAttention: def __init__(self, by_symbol_date: dict[tuple[str, dt.date], float | None]) -> None: self.by_symbol_date = by_symbol_date def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None: return self.by_symbol_date.get((symbol.upper(), as_of_date)) def _make_engine(**overrides: Any) -> StrategyEngineConfig: base: dict[str, Any] = dict( engine_id="earnings_runup_preevent_long", event_types=[EARNINGS_RUNUP_EVENT_TYPE], direction="long_only", timing_class="after_close", entry_timing_policy="next_open", max_holding_days=7, earnings_runup_enabled=True, earnings_runup_days_to_earnings_min=3, earnings_runup_days_to_earnings_max=7, earnings_runup_attention_zscore_20d_min=1.5, earnings_runup_dollar_volume_zscore_20d_min=1.0, earnings_runup_calendar_buffer_days=1, ) base.update(overrides) return StrategyEngineConfig(**base) def _generate_business_days(start: dt.date, count: int) -> list[dt.date]: out: list[dt.date] = [] cursor = start while len(out) < count: if cursor.weekday() < 5: out.append(cursor) cursor = cursor + dt.timedelta(days=1) return out def _build_bars( symbol: str, trading_days: list[dt.date], *, base_volume: float, spike_factor: float, base_close: float = 100.0, ) -> dict[str, dict[dt.date, dict[str, Any]]]: """Build a bars-by-symbol-date dict with the LAST bar's $-volume = base*spike_factor. Prior bars include small deterministic variance so sigma > 0 in the z-score. """ inner: dict[dt.date, dict[str, Any]] = {} for i, d in enumerate(trading_days): is_last = (i == len(trading_days) - 1) if is_last: volume = base_volume * spike_factor else: # +/- 10% sinusoidal perturbation, rounded so sigma > 0. jitter = 1.0 + 0.1 * ((i % 5) - 2) / 2.0 volume = base_volume * jitter inner[d] = { "open": base_close, "high": base_close, "low": base_close, "close": base_close, "volume": volume, } return {symbol.upper(): inner} # --------------------------------------------------------------------------- # evaluate_trigger() — happy path + 3 negative cases # --------------------------------------------------------------------------- def _trigger_inputs(**overrides: Any) -> EarningsRunupTriggerInputs: base: dict[str, Any] = dict( symbol="AAPL", decision_date=dt.date(2026, 4, 13), # Mon next_trading_date=dt.date(2026, 4, 14), upcoming_earnings_reaction_date=dt.date(2026, 4, 21), days_to_earnings=5, attention_zscore_20d=1.8, dollar_volume_zscore_20d=1.2, last_close_price=100.0, avg_dollar_volume_20d=200_000_000.0, last_bar_date=dt.date(2026, 4, 10), # prior Fri last_bar_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc), ) base.update(overrides) return EarningsRunupTriggerInputs(**base) def test_trigger_fires_when_all_three_conditions_met(): engine = _make_engine() passes, reason = evaluate_trigger(_trigger_inputs(), engine) assert passes is True assert reason is None def test_trigger_blocks_when_days_to_earnings_below_min(): engine = _make_engine() passes, reason = evaluate_trigger(_trigger_inputs(days_to_earnings=2), engine) assert passes is False assert "days_to_earnings" in (reason or "") def test_trigger_blocks_when_attention_zscore_below_min(): engine = _make_engine() passes, reason = evaluate_trigger(_trigger_inputs(attention_zscore_20d=1.4), engine) assert passes is False assert "attention_z" in (reason or "") def test_trigger_blocks_when_dollar_volume_zscore_below_min(): engine = _make_engine() passes, reason = evaluate_trigger(_trigger_inputs(dollar_volume_zscore_20d=0.99), engine) assert passes is False assert "dollar_volume_z" in (reason or "") # --------------------------------------------------------------------------- # build_earnings_runup_candidates() — end-to-end with fakes # --------------------------------------------------------------------------- def _build_full_setup( symbol: str = "AAPL", *, spike_factor: float = 5.0, attention_z: float | None = 2.0, earnings_offset_trading_days: int = 5, days_of_history: int = 30, ): # Decision day = the last day of generated trading days; bars go strictly before it. trading_days = _generate_business_days(dt.date(2026, 3, 2), days_of_history + earnings_offset_trading_days + 2) decision_date = trading_days[days_of_history] # T-1 close next_trading_date = trading_days[days_of_history + 1] earnings_reaction = trading_days[days_of_history + earnings_offset_trading_days] # Bars for prior `days_of_history` days, ending on the day BEFORE decision_date. prior_days = trading_days[:days_of_history] bars = _build_bars( symbol, prior_days, base_volume=1_000_000.0, spike_factor=spike_factor, ) bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars) # PIT calendar: known earnings reaction date for the symbol. pit_calendar = PointInTimeEarningsCalendar( [ EarningsCalendarEntry( symbol=symbol, as_of_date=trading_days[0], expected_reaction_date=earnings_reaction, expected_event_date=earnings_reaction, filing_time_bucket="post_market", ) ] ) upcoming_provider = _PitCalendarUpcomingEarningsAdapter( pit_calendar=pit_calendar, trading_days=trading_days, ) attention_provider = _FakeAttention( {(symbol.upper(), decision_date): attention_z} ) return { "symbol": symbol, "decision_date": decision_date, "next_trading_date": next_trading_date, "earnings_reaction": earnings_reaction, "trading_days": trading_days, "bar_provider": bar_provider, "upcoming_provider": upcoming_provider, "attention_provider": attention_provider, "bars": bars, } def test_build_emits_candidate_for_eligible_symbol(): setup = _build_full_setup() engine = _make_engine() cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 cand = cands[0] assert cand.event_type == EARNINGS_RUNUP_EVENT_TYPE assert cand.symbol == setup["symbol"] assert cand.engine_id == engine.engine_id assert cand.execution_date == setup["next_trading_date"] assert cand.engine_max_holding_days is not None # days_to_earnings was 5; calendar_buffer 1 → max_holding_days = 5 - 1 = 4 assert cand.engine_max_holding_days == 4 assert cand.features["earnings_runup_days_to_earnings"] == 5 assert cand.features["earnings_runup_stop_pct"] == 0.04 assert cand.features["earnings_runup_target_pct"] == 0.08 def test_build_does_not_fire_when_attention_below_min(): setup = _build_full_setup(attention_z=0.5) engine = _make_engine() cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert cands == [] def test_build_does_not_fire_when_dollar_volume_zscore_below_min(): # Spike factor of 1.0 (no spike) → z-score near 0 setup = _build_full_setup(spike_factor=1.0) engine = _make_engine() cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert cands == [] def test_build_does_not_fire_when_days_to_earnings_outside_window(): # earnings_offset = 10 trading days → > max 7 setup = _build_full_setup(earnings_offset_trading_days=10) engine = _make_engine() cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert cands == [] # --------------------------------------------------------------------------- # Lookahead defenses # --------------------------------------------------------------------------- def test_build_raises_lookahead_when_bar_date_equals_decision_date(): """A bar dated on or after decision_date must trigger LookaheadViolationError.""" setup = _build_full_setup() symbol = setup["symbol"] decision_date = setup["decision_date"] bars = setup["bars"] # Inject a bar dated ON decision_date — this is the look-ahead violation. bars[symbol.upper()][decision_date] = { "open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": 5_000_000.0, } bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars) # Add a sentinel bar AFTER decision_date too, so the adapter's `< as_of_date` filter # is the only thing keeping us safe. Then we manually subvert it. class LeakyAdapter: def get_bars_before(self, sym, as_of, lookback_days): inner = bars[sym.upper()] # Deliberately include the bar dated == decision_date. return sorted( [(d, b) for d, b in inner.items() if d <= as_of] )[-lookback_days:] engine = _make_engine() with pytest.raises(LookaheadViolationError): build_earnings_runup_candidates( decision_date=decision_date, next_trading_date=setup["next_trading_date"], universe_symbols=[symbol], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=LeakyAdapter(), ) def test_build_raises_lookahead_when_explicit_assertion_violated(): """Direct assertion path — feature timestamp >= cutoff must raise.""" from libs.backtest.earnings_runup import _assert_no_lookahead decision_date = dt.date(2026, 4, 13) # 09:30 ET on the decision day (= 13:30 UTC under EST; 13:30 UTC == 09:30 EST) leaky_ts = dt.datetime(2026, 4, 13, 14, 30, tzinfo=dt.timezone.utc) # 10:30 ET with pytest.raises(LookaheadViolationError): _assert_no_lookahead("AAPL", decision_date, [leaky_ts]) def test_assert_no_lookahead_accepts_strictly_prior_timestamp(): from libs.backtest.earnings_runup import _assert_no_lookahead decision_date = dt.date(2026, 4, 13) safe_ts = dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc) # prior day close # Should not raise _assert_no_lookahead("AAPL", decision_date, [safe_ts]) def test_assert_no_lookahead_rejects_naive_timestamp(): from libs.backtest.earnings_runup import _assert_no_lookahead decision_date = dt.date(2026, 4, 13) naive_ts = dt.datetime(2026, 4, 10, 21, 0) with pytest.raises(LookaheadViolationError): _assert_no_lookahead("AAPL", decision_date, [naive_ts]) # --------------------------------------------------------------------------- # PIT earnings calendar respects as_of_date # --------------------------------------------------------------------------- def test_pit_calendar_does_not_reveal_unannounced_future_earnings(): """Earnings dates whose as_of_date is AFTER decision_date must not be visible.""" trading_days = _generate_business_days(dt.date(2026, 3, 2), 30) decision_date = trading_days[10] earnings_reaction_date = trading_days[15] # Calendar entry was published AFTER decision_date — must be invisible. pit_calendar = PointInTimeEarningsCalendar( [ EarningsCalendarEntry( symbol="AAPL", as_of_date=trading_days[12], # > decision_date expected_reaction_date=earnings_reaction_date, expected_event_date=earnings_reaction_date, filing_time_bucket="post_market", ) ] ) adapter = _PitCalendarUpcomingEarningsAdapter( pit_calendar=pit_calendar, trading_days=trading_days, ) result = adapter.get_next_reaction_date( symbol="AAPL", as_of_date=decision_date, max_lookahead_calendar_days=14, ) assert result is None def test_pit_calendar_reveals_announced_future_earnings(): trading_days = _generate_business_days(dt.date(2026, 3, 2), 30) decision_date = trading_days[10] earnings_reaction_date = trading_days[15] pit_calendar = PointInTimeEarningsCalendar( [ EarningsCalendarEntry( symbol="AAPL", as_of_date=trading_days[5], # known well before decision_date expected_reaction_date=earnings_reaction_date, expected_event_date=earnings_reaction_date, filing_time_bucket="post_market", ) ] ) adapter = _PitCalendarUpcomingEarningsAdapter( pit_calendar=pit_calendar, trading_days=trading_days, ) result = adapter.get_next_reaction_date( symbol="AAPL", as_of_date=decision_date, max_lookahead_calendar_days=14, ) assert result == earnings_reaction_date # --------------------------------------------------------------------------- # Exit policy stub tests — verify candidate carries the exit configuration # --------------------------------------------------------------------------- def test_candidate_carries_stop_target_trailing_config_in_features(): setup = _build_full_setup() engine = _make_engine( earnings_runup_stop_pct=0.05, earnings_runup_target_pct=0.10, earnings_runup_trailing_activate_pct=0.06, earnings_runup_trailing_giveback_pct=0.025, ) cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 feats = cands[0].features assert feats["earnings_runup_stop_pct"] == 0.05 assert feats["earnings_runup_target_pct"] == 0.10 assert feats["earnings_runup_trailing_activate_pct"] == 0.06 assert feats["earnings_runup_trailing_giveback_pct"] == 0.025 def test_candidate_max_holding_days_forces_flat_before_print(): """Hard exit: max_holding_days = days_to_earnings - calendar_buffer_days (>=1).""" # 4 trading days to earnings, buffer 1 → max_hold = 3 setup = _build_full_setup(earnings_offset_trading_days=4) engine = _make_engine(earnings_runup_calendar_buffer_days=1) cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 assert cands[0].engine_max_holding_days == 3 def test_candidate_max_holding_days_never_below_one(): setup = _build_full_setup(earnings_offset_trading_days=3) engine = _make_engine(earnings_runup_calendar_buffer_days=5) # absurd buffer cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 assert cands[0].engine_max_holding_days >= 1 # --------------------------------------------------------------------------- # Behavioral exit tests — drive a synthetic position through simulate_exit and # verify pct exits map correctly to STOP / TARGET / TIME outcomes. # --------------------------------------------------------------------------- def _build_position_for_runup( *, entry_price: float = 100.0, stop_pct: float = 0.04, target_pct: float = 0.08, days_held: int = 0, ) -> Any: from libs.backtest.domain import ( Candidate, ExitReason, # noqa: F401 re-exported for downstream tests OpenPosition, PlannedOrder, ) # Mirror the candidate the production builder constructs. stop_mult = stop_pct / 0.02 target_r = target_pct / stop_pct synthetic_atr = entry_price * 0.02 cand = Candidate( event_id="evt_runup_exit", symbol="AAPL", score=0.75, sector="UNKNOWN", event_type=EARNINGS_RUNUP_EVENT_TYPE, event_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc), event_date=dt.date(2026, 4, 13), filing_time_bucket="post_market", reaction_date=dt.date(2026, 4, 13), execution_date=dt.date(2026, 4, 14), entry_price_est=entry_price, avg_dollar_volume=200_000_000.0, atr_14=synthetic_atr, score_bucket="medium_high", engine_id="earnings_runup_preevent_long", entry_timing_policy="next_open", trade_direction="long", engine_stop_atr_multiplier=stop_mult, engine_target_1_r=target_r, engine_target_1_fraction=1.0, engine_max_holding_days=4, ) stop_price = entry_price * (1.0 - stop_pct) target_price = entry_price * (1.0 + target_pct) plan = PlannedOrder( candidate=cand, shares=100, entry_price_limit=entry_price, stop_price=stop_price, target_price=target_price, risk_dollars=stop_pct * entry_price * 100, event_date=cand.event_date, timing_class="after_close", engine_id=cand.engine_id, entry_timing_policy="next_open", shadow_only=False, ) return OpenPosition( position_id="pos_runup", plan=plan, entry_date=cand.execution_date, entry_price=entry_price, entry_fill_slippage_bps=10.0, current_stop=stop_price, target_price=target_price, peak_price=entry_price, shares_open=100, shares_total=100, days_held=days_held, ) def _exec_config_for_exit_test() -> Any: from libs.backtest.domain import ExecutionConfig return ExecutionConfig( entry_fill_model="next_open", exit_fill_model="daily_bar_approximation", slippage_bps_base=10.0, commission_per_share=0.005, same_bar_priority="stop_first_conservative", max_holding_days=4, ) def test_exit_stop_at_minus_4pct(): """Long position with -4% stop must STOP-exit when bar.low <= 96.0.""" from libs.backtest.domain import ExitReason from libs.backtest.execution import simulate_exit pos = _build_position_for_runup(entry_price=100.0, stop_pct=0.04) # Bar drops to 95.5 → below the 96.0 stop → STOP exit. bar = {"date": dt.date(2026, 4, 15), "open": 99.0, "high": 99.5, "low": 95.5, "close": 96.5, "volume": 1_000_000} trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15)) assert trade is not None assert trade.exit_reason == ExitReason.STOP def test_exit_target_at_plus_8pct(): """Long position with +8% target must TARGET-exit when bar.high >= 108.0.""" from libs.backtest.domain import ExitReason from libs.backtest.execution import simulate_exit pos = _build_position_for_runup(entry_price=100.0, target_pct=0.08) bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 108.5, "low": 101.0, "close": 107.0, "volume": 1_000_000} trade = simulate_exit(pos, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15)) assert trade is not None assert trade.exit_reason == ExitReason.TARGET def test_exit_forced_max_hold_before_print(): """When days_held >= max_holding_days and no stop/target, exit reason is TIME.""" from libs.backtest.domain import ExitReason from libs.backtest.execution import simulate_exit # max_holding_days = 2; position already held 2 days. pos = _build_position_for_runup(entry_price=100.0, days_held=2) cfg = _exec_config_for_exit_test() cfg = cfg.model_copy(update={"max_holding_days": 2}) bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 103.0, "low": 99.0, "close": 102.5, "volume": 1_000_000} trade = simulate_exit(pos, bar, cfg, dt.date(2026, 4, 15)) assert trade is not None assert trade.exit_reason == ExitReason.TIME def test_exit_trailing_giveback_after_activation(): """Behavioral approximation of trailing exit: peak rises >+5%, then gives back >3%. The standard execution machinery does not natively implement the EarningsRunup pct-trailing model, so this test confirms the minimum invariant — when a trailing stop is RAISED to a level above the static stop and the bar's low touches it, the position exits via STOP. The trailing pct config is preserved on the candidate features for future engine wiring. """ from libs.backtest.domain import ExitReason from libs.backtest.execution import simulate_exit pos = _build_position_for_runup(entry_price=100.0) # Manually move stop up to 105.0 (= activation at 105 with 0% giveback for the test). raised = pos.model_copy(update={"current_stop": 105.0, "peak_price": 106.0}) bar = {"date": dt.date(2026, 4, 15), "open": 106.0, "high": 106.5, "low": 104.5, "close": 104.8, "volume": 1_000_000} trade = simulate_exit(raised, bar, _exec_config_for_exit_test(), dt.date(2026, 4, 15)) assert trade is not None assert trade.exit_reason == ExitReason.STOP # Confirm exit price is above original entry — i.e. the trailing stop captured profit. assert trade.exit_price > pos.entry_price # --------------------------------------------------------------------------- # Pct-trailing wiring tests — verify update_trailing_stop honors activation # threshold and entry-relative giveback (EarningsRunup-style trailing). # --------------------------------------------------------------------------- def _bar(*, low: float, high: float, open_: float | None = None, close: float | None = None) -> dict[str, Any]: return { "date": dt.date(2026, 4, 15), "open": open_ if open_ is not None else (low + high) / 2, "high": high, "low": low, "close": close if close is not None else (low + high) / 2, "volume": 1_000_000, } def test_pct_trailing_does_not_arm_below_activation_threshold(): """Position at +3% (activation=5%) → stop unchanged at -4% from entry.""" from libs.backtest.execution import update_trailing_stop pos = _build_position_for_runup(entry_price=100.0) # Bar with high=103 (+3% peak), low=101 → not yet at activation (+5%) update_trailing_stop( pos, _bar(low=101.0, high=103.0), trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) # peak should track high=103 assert pos.peak_price == 103.0 # stop should NOT have moved up — still at -4% = 96.0 assert pos.current_stop == 96.0 def test_pct_trailing_arms_at_activation_threshold(): """Peak hits +5% → stop raised to +2% (activation 5% - giveback 3% = 2%).""" from libs.backtest.execution import update_trailing_stop pos = _build_position_for_runup(entry_price=100.0) update_trailing_stop( pos, _bar(low=102.0, high=105.0), trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) # peak = 105, stop = peak - giveback*entry = 105 - 0.03*100 = 102.0 assert pos.peak_price == 105.0 assert pos.current_stop == pytest.approx(102.0) def test_pct_trailing_ratchets_up_with_continued_profit(): """Peak rises to +7% → stop raised to +4% (peak - 3% of entry).""" from libs.backtest.execution import update_trailing_stop pos = _build_position_for_runup(entry_price=100.0) # First armed at peak=105 → stop=102 update_trailing_stop( pos, _bar(low=102.0, high=105.0), trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) assert pos.current_stop == pytest.approx(102.0) # Continued profit: peak now 107 update_trailing_stop( pos, _bar(low=104.0, high=107.0), trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) assert pos.peak_price == 107.0 assert pos.current_stop == pytest.approx(104.0) def test_pct_trailing_does_not_ratchet_down_on_pullback(): """Peak 107 (stop 104) then peak holds at 107 while bar drops to 105 → stop stays at 104.""" from libs.backtest.execution import update_trailing_stop pos = _build_position_for_runup(entry_price=100.0) # Establish peak=107, stop=104 update_trailing_stop( pos, _bar(low=102.0, high=107.0), trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) assert pos.current_stop == pytest.approx(104.0) assert pos.peak_price == 107.0 # Pullback: bar high=106 (below previous peak), low=105 update_trailing_stop( pos, _bar(low=105.0, high=106.0), trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) # Peak unchanged; stop must stay (not ratchet down) assert pos.peak_price == 107.0 assert pos.current_stop == pytest.approx(104.0) def test_pct_trailing_reversal_triggers_stop_exit_with_profit(): """Peak +7% → stop +4%; then bar low touches +3% → position exits via STOP at +4%.""" from libs.backtest.domain import ExitReason from libs.backtest.execution import simulate_exit, update_trailing_stop pos = _build_position_for_runup(entry_price=100.0) # Day 1: peak rises to 107, stop ratchets to 104 update_trailing_stop( pos, _bar(low=102.0, high=107.0), trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) assert pos.current_stop == pytest.approx(104.0) # Day 2: bar opens at 105.5, drops to 103.5 → trailing stop at 104.0 hit. pos.days_held = 1 bar2 = _bar(low=103.5, high=105.5, open_=105.5, close=104.0) # First update peak (no new high) update_trailing_stop( pos, bar2, trailing_model="pct_3", warmup_days=0, pct_activation=0.05, pct_giveback=0.03, ) # Stop still at 104 (peak unchanged) assert pos.current_stop == pytest.approx(104.0) # Now simulate exit: bar.low=103.5 < stop=104 → STOP exit at 104 trade = simulate_exit(pos, bar2, _exec_config_for_exit_test(), dt.date(2026, 4, 15)) assert trade is not None assert trade.exit_reason == ExitReason.STOP # Exit price ~= 104.0 (the trailing stop, less small slippage), above entry → profit captured assert trade.exit_price == pytest.approx(104.0, rel=0.005) assert trade.exit_price > pos.entry_price def test_pct_trailing_legacy_behavior_when_activation_none(): """Regression: pct_3 with activation=None must trail unconditionally (legacy behavior).""" from libs.backtest.execution import update_trailing_stop pos = _build_position_for_runup(entry_price=100.0) # Even at +1% peak, legacy pct_3 trails to peak * 0.97 = 100*0.97 = 97 (only ratchets if > current_stop) # Note: position's current_stop starts at 96, and 101*0.97=97.97 > 96, so it should ratchet up. update_trailing_stop( pos, _bar(low=100.0, high=101.0), trailing_model="pct_3", warmup_days=0, pct_activation=None, pct_giveback=None, ) assert pos.peak_price == 101.0 # Legacy formula: peak * (1 - 0.03) = 97.97 (ratchets up from 96) assert pos.current_stop == pytest.approx(97.97) def test_pct_trailing_via_engine_candidate_in_effective_exec(): """End-to-end: candidate built from engine carries pct trailing fields into effective exec.""" from libs.backtest.execution import build_effective_execution_config setup = _build_full_setup() engine = _make_engine( earnings_runup_stop_pct=0.04, earnings_runup_target_pct=0.08, earnings_runup_trailing_activate_pct=0.05, earnings_runup_trailing_giveback_pct=0.03, ) cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 cand = cands[0] # Candidate has the new pct trailing fields assert cand.engine_trailing_model == "pct_3" assert cand.engine_trailing_pct_activation == pytest.approx(0.05) assert cand.engine_trailing_pct_giveback == pytest.approx(0.03) # Build a minimal BacktestConfig and check effective_exec inherits these from libs.backtest.domain import BacktestConfig, ExecutionConfig base_cfg = BacktestConfig( strategy_name="test", dataset_snapshot_id="test", execution=ExecutionConfig(max_holding_days=4), ) eff = build_effective_execution_config(cand, base_cfg) assert eff.trailing_model == "pct_3" assert eff.trailing_pct_activation == pytest.approx(0.05) assert eff.trailing_pct_giveback == pytest.approx(0.03) # --------------------------------------------------------------------------- # Capital-bucket silo propagation (regression for Phase 11 ER silo fix) # --------------------------------------------------------------------------- def test_build_propagates_capital_bucket_fields_when_set(): """When the engine config sets capital_bucket_id + allocation_pct, the synthetic Candidate must carry both into engine_capital_bucket_id / engine_capital_bucket_allocation_pct so the bucket-allocator silo applies. Pre-fix bug (Phase 11 diagnostic): these fields were dropped, making the silo a no-op. The earnings-runup engine then ran on the shared portfolio budget instead of its allocated bucket. """ setup = _build_full_setup() engine = _make_engine( capital_bucket_id="earnings_runup", capital_bucket_allocation_pct=0.15, ) cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 cand = cands[0] assert cand.engine_capital_bucket_id == "earnings_runup" assert cand.engine_capital_bucket_allocation_pct == pytest.approx(0.15) def test_build_defaults_capital_bucket_id_to_engine_id_when_only_alloc_set(): """If allocation_pct is set but capital_bucket_id is None, the candidate's bucket_id should fall back to the engine_id (mirrors selector.py convention). """ setup = _build_full_setup() engine = _make_engine( capital_bucket_id=None, capital_bucket_allocation_pct=0.20, ) cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 cand = cands[0] assert cand.engine_capital_bucket_id == engine.engine_id assert cand.engine_capital_bucket_allocation_pct == pytest.approx(0.20) def test_build_leaves_capital_bucket_fields_none_when_engine_has_none(): """Default behaviour (no silo): both bucket fields on the candidate must be None.""" setup = _build_full_setup() engine = _make_engine() # neither field set cands = build_earnings_runup_candidates( decision_date=setup["decision_date"], next_trading_date=setup["next_trading_date"], universe_symbols=[setup["symbol"]], engine=engine, upcoming_earnings_provider=setup["upcoming_provider"], attention_provider=setup["attention_provider"], bar_provider=setup["bar_provider"], ) assert len(cands) == 1 cand = cands[0] assert cand.engine_capital_bucket_id is None assert cand.engine_capital_bucket_allocation_pct is None