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.
390 lines
15 KiB
Python
390 lines
15 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,
|
|
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 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)
|
|
|
|
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)
|