"""Unit tests for libs/backtest/execution.py.""" from __future__ import annotations import datetime as dt from zoneinfo import ZoneInfo import pytest from libs.backtest.domain import ( Candidate, ExecutionConfig, ExitReason, OpenPosition, PlannedOrder, PositionStatus, ) _UTC = ZoneInfo("UTC") _NOW = dt.datetime(2026, 1, 5, 21, 0, tzinfo=_UTC) _TODAY = dt.date(2026, 1, 5) _TOMORROW = dt.date(2026, 1, 6) _DAY3 = dt.date(2026, 1, 7) def _make_candidate(**kwargs) -> Candidate: return Candidate( event_id="EVT::TEST", symbol="AAPL", score=0.8, sector="Technology", event_type="earnings", event_timestamp=_NOW, filing_time_bucket="post_market", reaction_date=_TODAY, execution_date=_TOMORROW, entry_price_est=100.0, avg_dollar_volume=5_000_000.0, atr_14=2.0, score_bucket="high", **kwargs, ) def _make_plan(entry_price=100.0, stop=95.0, target=110.0, shares=100) -> PlannedOrder: return PlannedOrder( candidate=_make_candidate(), shares=shares, entry_price_limit=entry_price, stop_price=stop, target_price=target, risk_dollars=500.0, ) def _make_bar(open=101.0, high=108.0, low=98.0, close=105.0, date=None) -> dict: return { "date": date or _TOMORROW, "open": open, "high": high, "low": low, "close": close, "volume": 1_000_000, } def _make_exec_config(**kwargs) -> ExecutionConfig: defaults = dict( 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=10, ) defaults.update(kwargs) return ExecutionConfig(**defaults) def _make_open_position(entry_price=101.0, stop=95.0, target=110.0, days=0, shares=100) -> OpenPosition: plan = _make_plan(entry_price=100.0, stop=stop, target=target, shares=shares) return OpenPosition( position_id="p1", plan=plan, entry_date=_TODAY, entry_price=entry_price, entry_fill_slippage_bps=10.0, current_stop=stop, target_price=target, peak_price=entry_price, shares_open=shares, shares_total=shares, days_held=days, ) class TestSimulateEntry: def test_basic_entry(self): from libs.backtest.execution import simulate_entry plan = _make_plan() bar = _make_bar(open=100.0) cfg = _make_exec_config(slippage_bps_base=10.0) pos = simulate_entry(plan, bar, cfg) assert pos is not None # Entry fill = open * (1 + 10/10000) expected = 100.0 * (1 + 10 / 10_000) assert pos.entry_price == pytest.approx(expected) def test_missing_bar_returns_none(self): from libs.backtest.execution import simulate_entry plan = _make_plan() assert simulate_entry(plan, None, _make_exec_config()) is None def test_zero_open_returns_none(self): from libs.backtest.execution import simulate_entry plan = _make_plan() bar = _make_bar(open=0.0) assert simulate_entry(plan, bar, _make_exec_config()) is None def test_rejected_plan_returns_none(self): from libs.backtest.execution import simulate_entry plan = PlannedOrder( candidate=_make_candidate(), shares=100, entry_price_limit=100.0, stop_price=95.0, target_price=110.0, risk_dollars=500.0, skip_reason="max_positions_reached", ) bar = _make_bar() assert simulate_entry(plan, bar, _make_exec_config()) is None def test_entry_date_from_bar(self): from libs.backtest.execution import simulate_entry plan = _make_plan() bar = _make_bar(date=_TOMORROW) pos = simulate_entry(plan, bar, _make_exec_config()) assert pos.entry_date == _TOMORROW class TestSimulateExit: def test_stop_exit(self): from libs.backtest.execution import simulate_exit pos = _make_open_position(entry_price=101.0, stop=95.0, target=115.0) bar = _make_bar(low=90.0, high=100.0) # low < stop cfg = _make_exec_config() trade = simulate_exit(pos, bar, cfg, _TOMORROW) assert trade is not None assert trade.exit_reason == ExitReason.STOP # Fill at stop * (1 - slippage) expected = 95.0 * (1 - 10 / 10_000) assert trade.exit_price == pytest.approx(expected) def test_target_exit(self): from libs.backtest.execution import simulate_exit pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0) bar = _make_bar(low=102.0, high=115.0) # high > target trade = simulate_exit(pos, bar, _make_exec_config(), _TOMORROW) assert trade is not None assert trade.exit_reason == ExitReason.TARGET expected = 110.0 * (1 - 10 / 10_000) assert trade.exit_price == pytest.approx(expected) def test_same_bar_stop_first_conservative(self): from libs.backtest.execution import simulate_exit pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0) bar = _make_bar(low=90.0, high=115.0) # both stop AND target hit cfg = _make_exec_config(same_bar_priority="stop_first_conservative") trade = simulate_exit(pos, bar, cfg, _TOMORROW) assert trade.exit_reason == ExitReason.STOP def test_same_bar_target_first_aggressive(self): from libs.backtest.execution import simulate_exit pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0) bar = _make_bar(low=90.0, high=115.0) cfg = _make_exec_config(same_bar_priority="target_first_aggressive") trade = simulate_exit(pos, bar, cfg, _TOMORROW) assert trade.exit_reason == ExitReason.TARGET def test_time_exit(self): from libs.backtest.execution import simulate_exit pos = _make_open_position(entry_price=101.0, stop=95.0, target=120.0, days=10) bar = _make_bar(low=100.0, high=105.0, close=103.0) # no stop or target hit cfg = _make_exec_config(max_holding_days=10) trade = simulate_exit(pos, bar, cfg, _TOMORROW) assert trade is not None assert trade.exit_reason == ExitReason.TIME def test_no_exit_when_bar_in_range(self): from libs.backtest.execution import simulate_exit pos = _make_open_position(entry_price=101.0, stop=95.0, target=120.0, days=3) bar = _make_bar(low=98.0, high=108.0) trade = simulate_exit(pos, bar, _make_exec_config(), _TOMORROW) assert trade is None def test_missing_bar_no_exit(self): from libs.backtest.execution import simulate_exit pos = _make_open_position() assert simulate_exit(pos, None, _make_exec_config(), _TOMORROW) is None def test_r_multiple_uses_actual_fill(self): from libs.backtest.execution import simulate_exit # entry=101, stop=95 → risk per share = 6 pos = _make_open_position(entry_price=101.0, stop=95.0, target=113.0) bar = _make_bar(low=98.0, high=115.0) # target hit trade = simulate_exit(pos, bar, _make_exec_config(slippage_bps_base=0.0), _TOMORROW) # R-multiple = (exit - entry) / (entry - stop) = (113 - 101) / (101 - 95) = 12/6 = 2.0 assert trade.r_multiple == pytest.approx(2.0, rel=0.01) def test_pnl_includes_commission(self): from libs.backtest.execution import simulate_exit pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100) bar = _make_bar(low=98.0, high=115.0) # target hit cfg = _make_exec_config(slippage_bps_base=0.0, commission_per_share=0.01) trade = simulate_exit(pos, bar, cfg, _TOMORROW) # gross = (110 - 100) * 100 = 1000 # commission = 100 * 0.01 * 2 = 2.0 assert trade.gross_pnl == pytest.approx(1000.0) assert trade.net_pnl == pytest.approx(998.0) class TestUpdateTrailingStop: def test_ratchets_up(self): from libs.backtest.execution import update_trailing_stop pos = _make_open_position(stop=95.0) update_trailing_stop(pos, _make_bar(low=97.0)) assert pos.current_stop == pytest.approx(97.0) def test_never_moves_down(self): from libs.backtest.execution import update_trailing_stop pos = _make_open_position(stop=95.0) update_trailing_stop(pos, _make_bar(low=92.0)) assert pos.current_stop == pytest.approx(95.0) def test_updates_peak_price(self): from libs.backtest.execution import update_trailing_stop pos = _make_open_position(entry_price=100.0) 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)