You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

534 lines
21 KiB
Python

"""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,
event_date=_TODAY,
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,
event_date=_TODAY,
timing_class="same_day",
engine_id="engine_1",
entry_timing_policy="next_open",
shadow_only=False,
)
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
def test_reaction_close_entry_uses_close(self):
from libs.backtest.execution import simulate_entry
plan = _make_plan()
plan = plan.model_copy(update={"entry_timing_policy": "reaction_close"})
bar = _make_bar(open=100.0, close=102.0, date=_TODAY)
pos = simulate_entry(plan, bar, _make_exec_config(slippage_bps_base=10.0))
assert pos is not None
expected = 102.0 * (1 + 10 / 10_000)
assert pos.entry_price == pytest.approx(expected)
assert pos.entry_date == _TODAY
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_expected_decay_exit_triggers_at_close(self):
from libs.backtest.execution import simulate_exit
plan = _make_plan(entry_price=100.0, stop=95.0, target=120.0, shares=100)
plan = plan.model_copy(
update={"candidate": plan.candidate.model_copy(update={"score": 0.45})}
)
pos = OpenPosition(
position_id="p1",
plan=plan,
entry_date=_TODAY,
entry_price=101.0,
entry_fill_slippage_bps=10.0,
current_stop=95.0,
target_price=120.0,
peak_price=104.0,
shares_open=100,
shares_total=100,
days_held=9,
)
bar = _make_bar(low=99.0, high=104.0, close=102.0)
cfg = _make_exec_config(
max_holding_days=20,
expected_decay_exit_enabled=True,
expected_decay_lambda=0.15,
expected_decay_score_floor=0.12,
expected_decay_min_days_held=4,
)
trade = simulate_exit(pos, bar, cfg, _TOMORROW)
assert trade is not None
assert trade.exit_reason == ExitReason.DECAY
def test_expected_decay_exit_respects_min_days_and_floor(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, close=103.0)
cfg = _make_exec_config(
max_holding_days=20,
expected_decay_exit_enabled=True,
expected_decay_lambda=0.15,
expected_decay_score_floor=0.12,
expected_decay_min_days_held=4,
)
trade = simulate_exit(pos, bar, cfg, _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_recycle_close_exit_uses_close(self):
from libs.backtest.execution import simulate_recycle_close_exit
pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
bar = _make_bar(close=107.0)
trade = simulate_recycle_close_exit(pos, bar, _TOMORROW, _make_exec_config(slippage_bps_base=10.0))
assert trade is not None
assert trade.exit_reason == ExitReason.RECYCLE
expected = 107.0 * (1 - 10 / 10_000)
assert trade.exit_price == pytest.approx(expected)
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)
def test_trade_metadata_propagated(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)
trade = simulate_exit(pos, bar, _make_exec_config(slippage_bps_base=0.0), _TOMORROW)
assert trade.engine_id == "engine_1"
assert trade.timing_class == "same_day"
assert trade.event_date == _TODAY
assert trade.shadow_only is False
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
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)
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)
class TestScheduledOpenExit:
def test_full_exit_executes_at_next_open(self):
from libs.backtest.execution import simulate_scheduled_open_exit
pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
bar = _make_bar(open=97.0, high=100.0, low=96.0, close=98.0, date=_TOMORROW)
trade = simulate_scheduled_open_exit(
position=pos,
bar=bar,
config=_make_exec_config(slippage_bps_base=0.0),
current_date=_TOMORROW,
reason="EARLY_FAILURE",
fraction=1.0,
)
assert trade is not None
assert trade.exit_reason == ExitReason.EARLY_FAILURE
assert trade.exit_price == pytest.approx(97.0)
assert trade.shares == 100
def test_partial_no_progress_exit_leaves_position_open(self):
from libs.backtest.execution import simulate_scheduled_open_exit
pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
bar = _make_bar(open=101.0, high=103.0, low=99.0, close=102.0, date=_TOMORROW)
trade = simulate_scheduled_open_exit(
position=pos,
bar=bar,
config=_make_exec_config(slippage_bps_base=0.0),
current_date=_TOMORROW,
reason="NO_PROGRESS",
fraction=0.5,
)
assert trade is not None
assert trade.exit_reason == ExitReason.NO_PROGRESS
assert trade.shares == 50
assert pos.shares_open == 50
assert pos.status == PositionStatus.PARTIALLY_EXITED
def test_giveback_exit_uses_giveback_reason(self):
from libs.backtest.execution import simulate_scheduled_open_exit
pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
bar = _make_bar(open=104.0, high=105.0, low=103.0, close=104.0, date=_TOMORROW)
trade = simulate_scheduled_open_exit(
position=pos,
bar=bar,
config=_make_exec_config(slippage_bps_base=0.0),
current_date=_TOMORROW,
reason="GIVEBACK",
fraction=1.0,
)
assert trade is not None
assert trade.exit_reason == ExitReason.GIVEBACK
assert trade.exit_price == pytest.approx(104.0)
assert trade.shares == 100
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)