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.
630 lines
23 KiB
Python
630 lines
23 KiB
Python
"""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
|