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.

901 lines
34 KiB
Python

"""Unit tests for the VolBreakout52w engine.
Heavy emphasis on look-ahead defenses — this engine is the honest descendant of
the retired topgainer v1-v54 lineage which collapsed +267% / Sharpe 13.73 →
-4.3% / Sharpe -1.04 once Phase-1's daily_high look-ahead was removed
(memory: project_topgainer_phase1_lookahead_2026-05-05.md). The defense MUST be
airtight.
"""
from __future__ import annotations
import datetime as dt
import random
from typing import Any
import pytest
from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig
from libs.backtest.vol_breakout_52w import (
VOL_BREAKOUT_52W_EVENT_TYPE,
FrozenT1Features,
VolBreakout52wTriggerInputs,
_SnapshotStoreBarAdapter,
_assert_features_strictly_before_decision_open,
build_candidates,
compute_52w_high_breakout,
compute_atr_normalized,
compute_volume_ratio,
evaluate_trigger,
)
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def _make_engine(**overrides: Any) -> StrategyEngineConfig:
base: dict[str, Any] = dict(
engine_id="vol_breakout_52w_long",
event_types=[VOL_BREAKOUT_52W_EVENT_TYPE],
direction="long_only",
timing_class="after_close",
entry_timing_policy="next_open",
max_holding_days=2,
vol_breakout_52w_enabled=True,
vol_breakout_52w_lookback_days=60, # smaller for tests
vol_breakout_52w_volume_ratio_min=2.0,
vol_breakout_52w_volume_median_window=20,
vol_breakout_52w_atr_normalized_min=0.015,
vol_breakout_52w_atr_normalized_max=0.06,
vol_breakout_52w_pre_open_gap_max=0.04,
vol_breakout_52w_skip_if_no_gap_data=True,
vol_breakout_52w_min_avg_dollar_volume=10_000_000.0,
vol_breakout_52w_min_price=5.0,
vol_breakout_52w_stop_pct=0.03,
vol_breakout_52w_target_pct=0.05,
vol_breakout_52w_max_holding_days=2,
)
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_close: float = 100.0,
base_high: float = 100.5,
base_low: float = 99.5,
base_volume: float = 5_000_000.0,
last_close: float | None = None,
last_high: float | None = None,
last_low: float | None = None,
last_volume: float | None = None,
atr_jitter: float = 0.5,
) -> dict[str, dict[dt.date, dict[str, Any]]]:
"""Construct a dict-of-dicts bars store for the single symbol.
Default: flat history at base_close, with a small atr_jitter on H-L.
Customize the FINAL bar (T-1 in tests) via the ``last_*`` arguments.
"""
inner: dict[dt.date, dict[str, Any]] = {}
n = len(trading_days)
for i, d in enumerate(trading_days):
is_last = (i == n - 1)
if is_last and last_close is not None:
close_v = last_close
high_v = last_high if last_high is not None else last_close + 0.5
low_v = last_low if last_low is not None else last_close - 0.5
volume_v = last_volume if last_volume is not None else base_volume
else:
close_v = base_close + (i % 7) * 0.05 # tiny drift, never crosses base_high
high_v = base_high + (i % 5) * 0.1 * atr_jitter
low_v = base_low - (i % 5) * 0.1 * atr_jitter
volume_v = base_volume * (1.0 + 0.02 * ((i % 5) - 2))
inner[d] = {
"open": close_v,
"high": high_v,
"low": low_v,
"close": close_v,
"volume": volume_v,
}
return {symbol.upper(): inner}
# ---------------------------------------------------------------------------
# Pure feature computations
# ---------------------------------------------------------------------------
def test_compute_52w_high_breakout_fires_when_close_above_window_max():
days = _generate_business_days(dt.date(2026, 1, 5), 70)
bars = _build_bars("AAPL", days, base_close=100.0, base_high=110.0,
last_close=120.0, last_high=121.0, last_low=119.0)["AAPL"]
series = sorted(bars.items())
is_b, last_close, prior_max, used = compute_52w_high_breakout(series, lookback_days=60)
assert is_b is True
assert last_close == 120.0
assert prior_max <= 110.5 # base_high + small jitter
assert len(used) >= 20
def test_compute_52w_high_breakout_does_not_fire_when_close_at_or_below_max():
days = _generate_business_days(dt.date(2026, 1, 5), 70)
bars = _build_bars("AAPL", days, base_close=100.0, base_high=110.0,
last_close=109.0, last_high=109.5, last_low=108.5)["AAPL"]
series = sorted(bars.items())
is_b, _last_close, prior_max, _used = compute_52w_high_breakout(series, lookback_days=60)
assert is_b is False
assert prior_max >= 109.0
def test_compute_volume_ratio_and_atr_normalized_basic():
days = _generate_business_days(dt.date(2026, 1, 5), 35)
bars = _build_bars("AAPL", days, base_volume=1_000_000.0, last_volume=4_000_000.0,
last_close=100.0, last_high=102.0, last_low=98.0)["AAPL"]
series = sorted(bars.items())
vol_t1, median_t2 = compute_volume_ratio(series, median_window=20)
assert vol_t1 == 4_000_000.0
assert median_t2 == pytest.approx(1_000_000.0, rel=0.05)
atr_norm = compute_atr_normalized(series, window=14)
assert atr_norm is not None
assert 0.005 < atr_norm < 0.06 # synthetic data should lie in a sane band
# ---------------------------------------------------------------------------
# evaluate_trigger — happy path + 4 negative cases
# ---------------------------------------------------------------------------
def _trigger_inputs(**overrides: Any) -> VolBreakout52wTriggerInputs:
base: dict[str, Any] = dict(
symbol="AAPL",
decision_date=dt.date(2026, 4, 13),
next_trading_date=dt.date(2026, 4, 14),
last_bar_date=dt.date(2026, 4, 10),
last_bar_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
last_close=120.0,
prior_252d_max_high=110.0,
is_52w_breakout=True,
volume_t_minus_1=4_000_000.0,
median_volume_20d_t_minus_2=1_000_000.0,
atr_normalized_t_minus_1=0.030,
avg_dollar_volume_20d=200_000_000.0,
pre_open_gap_pct=0.01,
)
base.update(overrides)
return VolBreakout52wTriggerInputs(**base)
def test_trigger_fires_when_all_three_conditions_met():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(), engine)
assert passes is True, reason
assert reason is None
def test_trigger_blocks_when_not_a_breakout():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(is_52w_breakout=False), engine)
assert passes is False
assert "prior 252d max high" in (reason or "")
def test_trigger_blocks_when_volume_ratio_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(volume_t_minus_1=1_500_000.0), engine)
assert passes is False
assert "volume_ratio" in (reason or "")
def test_trigger_blocks_when_atr_below_band():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(atr_normalized_t_minus_1=0.010), engine)
assert passes is False
assert "atr_normalized" in (reason or "")
def test_trigger_blocks_when_atr_above_band_parabolic():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(atr_normalized_t_minus_1=0.080), engine)
assert passes is False
assert "atr_normalized" in (reason or "")
def test_trigger_blocks_when_price_below_min():
engine = _make_engine(vol_breakout_52w_min_price=10.0)
passes, reason = evaluate_trigger(_trigger_inputs(last_close=4.0), engine)
assert passes is False
assert "min price" in (reason or "")
def test_trigger_blocks_when_adv_below_min():
engine = _make_engine(vol_breakout_52w_min_avg_dollar_volume=50_000_000.0)
passes, reason = evaluate_trigger(_trigger_inputs(avg_dollar_volume_20d=10_000_000.0), engine)
assert passes is False
assert "avg_dollar_volume" in (reason or "")
# ---------------------------------------------------------------------------
# Pre-open gap fade guard
# ---------------------------------------------------------------------------
def test_trigger_blocks_when_pre_open_gap_exceeds_max():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=0.05), engine)
assert passes is False
assert "pre_open_gap" in (reason or "")
def test_trigger_passes_when_pre_open_gap_within_max():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=0.03), engine)
assert passes is True
assert reason is None
def test_trigger_passes_when_pre_open_gap_data_missing_and_skip_flag_true():
"""Missing gap data + flag=True → no enforcement (skip-with-warning path)."""
engine = _make_engine(vol_breakout_52w_skip_if_no_gap_data=True)
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=None), engine)
assert passes is True
assert reason is None
def test_trigger_passes_when_pre_open_gap_data_missing_and_skip_flag_false():
"""Missing gap data + flag=False → also no enforcement (we cannot enforce a
guard with no data; the warning is logged at the build level)."""
engine = _make_engine(vol_breakout_52w_skip_if_no_gap_data=False)
passes, reason = evaluate_trigger(_trigger_inputs(pre_open_gap_pct=None), engine)
assert passes is True
assert reason is None
# ---------------------------------------------------------------------------
# Look-ahead defenses — the load-bearing tests for this engine
# ---------------------------------------------------------------------------
def test_assert_no_lookahead_rejects_t0_intraday_timestamp():
"""A feature timestamp at 10:30 ET on decision_date is a categorical look-ahead."""
decision_date = dt.date(2026, 4, 13)
leaky_ts = dt.datetime(2026, 4, 13, 14, 30, tzinfo=dt.timezone.utc) # 10:30 ET
with pytest.raises(LookaheadViolationError):
_assert_features_strictly_before_decision_open(
"AAPL", decision_date, [leaky_ts]
)
def test_assert_no_lookahead_rejects_naive_timestamp():
decision_date = dt.date(2026, 4, 13)
with pytest.raises(LookaheadViolationError):
_assert_features_strictly_before_decision_open(
"AAPL", decision_date, [dt.datetime(2026, 4, 10, 21, 0)]
)
def test_assert_no_lookahead_accepts_strictly_prior_timestamp():
decision_date = dt.date(2026, 4, 13)
safe_ts = dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc)
_assert_features_strictly_before_decision_open(
"AAPL", decision_date, [safe_ts]
) # must NOT raise
def test_evaluate_trigger_re_asserts_last_bar_strictly_before_decision_date():
"""Defence-in-depth: even if a leaky provider snuck through, evaluate_trigger
must trip on ``last_bar_date >= decision_date``. This is the categorical
catch for the topgainer v1-v54 bug."""
engine = _make_engine()
inputs = _trigger_inputs(
decision_date=dt.date(2026, 4, 13),
last_bar_date=dt.date(2026, 4, 13), # SAME DAY — look-ahead
)
with pytest.raises(LookaheadViolationError):
evaluate_trigger(inputs, engine)
def test_frozen_t1_features_blocks_forbidden_field_substring():
"""FrozenT1Features must refuse extras whose names encode T+0 data."""
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
with pytest.raises(LookaheadViolationError) as excinfo:
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[],
extra={"daily_high": 121.0}, # forbidden — encodes T+0 data
)
assert "daily_high" in str(excinfo.value)
def test_frozen_t1_features_blocks_daily_close_extra():
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
with pytest.raises(LookaheadViolationError):
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[],
extra={"reaction_daily_close": 121.0},
)
def test_frozen_t1_features_blocks_t0_window_date():
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
with pytest.raises(LookaheadViolationError):
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[decision_date], # T+0 — forbidden
)
def test_frozen_t1_features_blocks_last_bar_at_or_after_decision_date():
decision_date = dt.date(2026, 4, 13)
with pytest.raises(LookaheadViolationError):
FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=decision_date, # same-day — forbidden
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[],
)
def test_frozen_t1_features_accepts_strictly_prior_data():
decision_date = dt.date(2026, 4, 13)
last_bar_date = dt.date(2026, 4, 10)
fts = FrozenT1Features(
symbol="AAPL",
decision_date=decision_date,
last_bar_date=last_bar_date,
last_close=120.0,
high_252d_max=110.0,
high_252d_max_window=[dt.date(2026, 1, 5), dt.date(2026, 4, 9)],
extra={"vol_breakout_52w_volume_ratio": 4.0},
)
assert fts.last_bar_date == last_bar_date
# ---------------------------------------------------------------------------
# build_candidates — leaky-provider proof-by-contradiction (the test that
# would have caught the topgainer v1-v54 bug)
# ---------------------------------------------------------------------------
def _build_full_setup(*, days_of_history: int = 80, breakout: bool = True,
vol_spike: float = 4.0):
days = _generate_business_days(dt.date(2026, 1, 5), days_of_history + 2)
decision_date = days[days_of_history]
next_trading_date = days[days_of_history + 1]
prior_days = days[:days_of_history]
last_close = 103.0 if breakout else 101.0
# Tight base H/L (100 +/- 1.5) keeps ATR/close ~0.02 — within the [0.015, 0.06] band.
bars = _build_bars(
"AAPL",
prior_days,
base_close=100.0,
base_high=101.5,
base_low=98.5,
base_volume=1_000_000.0,
last_close=last_close,
last_high=last_close + 1.0,
last_low=last_close - 1.0,
last_volume=int(1_000_000.0 * vol_spike),
atr_jitter=0.3,
)
return {
"symbol": "AAPL",
"decision_date": decision_date,
"next_trading_date": next_trading_date,
"trading_days": days,
"bars": bars,
"bar_provider": _SnapshotStoreBarAdapter(bars_by_symbol=bars),
}
def test_build_emits_candidate_for_eligible_symbol():
setup = _build_full_setup()
engine = _make_engine()
cands = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
assert len(cands) == 1
cand = cands[0]
assert cand.event_type == VOL_BREAKOUT_52W_EVENT_TYPE
assert cand.symbol == "AAPL"
assert cand.execution_date == setup["next_trading_date"]
assert cand.engine_max_holding_days == 2
assert cand.features["vol_breakout_52w_stop_pct"] == 0.03
assert cand.features["vol_breakout_52w_target_pct"] == 0.05
# Defence-in-depth: candidate's event_timestamp must be strictly before
# 09:30 ET on the decision_date.
assert cand.event_timestamp.date() < setup["decision_date"]
def test_build_does_not_fire_when_not_a_breakout():
setup = _build_full_setup(breakout=False)
engine = _make_engine()
cands = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
assert cands == []
def test_build_does_not_fire_when_volume_ratio_below_min():
setup = _build_full_setup(vol_spike=1.2)
engine = _make_engine()
cands = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
assert cands == []
def test_build_raises_lookahead_when_provider_returns_t0_bar():
"""Inject a deliberately leaky provider that returns a bar dated == decision_date.
The engine MUST raise LookaheadViolationError. This is the proof-by-
contradiction test against the topgainer v1-v54 class of bug.
"""
setup = _build_full_setup()
decision_date = setup["decision_date"]
bars = setup["bars"]
# Inject a bar dated ON decision_date.
bars["AAPL"][decision_date] = {
"open": 122.0, "high": 130.0, "low": 121.0, "close": 129.0,
"volume": 9_000_000.0,
}
class LeakyAdapter:
"""Leaks T+0 bar into the screener — a topgainer-style bug."""
def get_bars_before(self, sym, as_of, lookback_days):
inner = bars[sym.upper()]
# Deliberately INCLUDE the bar dated == as_of_date.
ordered = sorted([(d, b) for d, b in inner.items() if d <= as_of])
return ordered[-lookback_days:]
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_candidates(
decision_date=decision_date,
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=LeakyAdapter(),
pre_open_gap_provider=None,
)
def test_build_raises_when_next_trading_date_not_strictly_after_decision_date():
setup = _build_full_setup()
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["decision_date"], # same day — forbidden
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
# ---------------------------------------------------------------------------
# Honest-replay test — clean vs leaky provider on identical data must produce
# either identical (clean ↔ clean) or raise (leaky). Deliberately leaky data
# must NOT silently produce different (better) candidates.
# ---------------------------------------------------------------------------
def test_honest_replay_clean_provider_is_deterministic():
setup = _build_full_setup()
engine = _make_engine()
cands_a = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
cands_b = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
# Identical inputs → identical outputs (modulo event_id which encodes inputs).
assert len(cands_a) == len(cands_b) == 1
assert cands_a[0].symbol == cands_b[0].symbol
assert cands_a[0].entry_price_est == cands_b[0].entry_price_est
assert cands_a[0].features == cands_b[0].features
def test_honest_replay_zero_shift_vs_minus1_shift_produces_identical_results():
"""Shift the source bars by 0 vs -1 day. With strict-before discipline,
both views generate the same trigger because the engine never reads T+0.
Specifically: take a setup whose decision_date is D. Then:
- Clean view: bars dated < D.
- Shifted-by-(-1) view: bars dated <= D-1 (== bars < D). SAME SET.
The key invariant is that 0-shift (no extra bar) and explicit -1 shift
yield identical candidates because we honor strict-before T.
"""
setup = _build_full_setup()
engine = _make_engine()
# View A: standard (strict-before T).
cands_a = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=setup["bar_provider"],
pre_open_gap_provider=None,
)
# View B: explicitly truncate to bars dated <= decision_date - 1 day.
truncated_bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym, sym_bars in setup["bars"].items():
truncated_bars[sym] = {
d: b for d, b in sym_bars.items()
if d < setup["decision_date"] # explicit -1 shift floor
}
cands_b = build_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=truncated_bars),
pre_open_gap_provider=None,
)
assert len(cands_a) == len(cands_b)
if cands_a:
assert cands_a[0].entry_price_est == cands_b[0].entry_price_est
# The breakout-determining stats must agree.
assert cands_a[0].features["vol_breakout_52w_last_close"] == \
cands_b[0].features["vol_breakout_52w_last_close"]
assert cands_a[0].features["vol_breakout_52w_prior_252d_max_high"] == \
cands_b[0].features["vol_breakout_52w_prior_252d_max_high"]
# ---------------------------------------------------------------------------
# Bootstrap permutation test — the engine has no notion of label permutation;
# the test we CAN run is: shuffle decision_date assignments across symbols and
# assert that each symbol's trigger output is unchanged because each candidate
# is computed only from THAT symbol's bars (no cross-symbol leakage).
# ---------------------------------------------------------------------------
def test_bootstrap_permutation_per_symbol_independence():
"""Per-symbol independence: scrambling the order of universe_symbols must
not change the set of emitted candidates. If it does, there is hidden
cross-symbol state leaking into the trigger.
"""
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym in ("AAPL", "MSFT", "GOOG"):
bars.update(_build_bars(
sym, prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1_000_000.0,
last_close=120.0, # all break out
last_high=121.0, last_low=119.0,
last_volume=4_000_000.0,
))
bar_provider = _SnapshotStoreBarAdapter(bars_by_symbol=bars)
engine = _make_engine()
rng = random.Random(12345)
base_order = ["AAPL", "MSFT", "GOOG"]
base = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=base_order,
engine=engine,
bar_provider=bar_provider,
pre_open_gap_provider=None,
)
base_symbols = sorted(c.symbol for c in base)
assert base_symbols == ["AAPL", "GOOG", "MSFT"]
for _ in range(8):
order = list(base_order)
rng.shuffle(order)
shuffled = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=order,
engine=engine,
bar_provider=bar_provider,
pre_open_gap_provider=None,
)
assert sorted(c.symbol for c in shuffled) == base_symbols
def test_bootstrap_permutation_breaks_edge_when_signal_is_destroyed():
"""If we permute the LAST-bar values across symbols (so the breakout flag
no longer corresponds to the symbol's own history), a symbol's eligibility
must depend ONLY on its own bars. Permuting the universe order alone does
NOT change candidates — that's the test above. Here we instead verify that
forcing one symbol's last-close to a NON-breakout level removes ONLY that
symbol from the candidate list, leaving the others intact.
"""
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym, last_close in (("AAPL", 120.0), ("MSFT", 120.0), ("GOOG", 120.0)):
bars.update(_build_bars(
sym, prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1_000_000.0,
last_close=last_close,
last_high=last_close + 1.0, last_low=last_close - 1.0,
last_volume=4_000_000.0,
))
engine = _make_engine()
base = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["AAPL", "MSFT", "GOOG"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=bars),
pre_open_gap_provider=None,
)
assert sorted(c.symbol for c in base) == ["AAPL", "GOOG", "MSFT"]
# Now: kill MSFT's breakout by lowering its last close BELOW prior 252d max high
# (base_high=102 plus jitter ~ 102.2). last_close=100 is clearly not a breakout.
bars2: dict[str, dict[dt.date, dict[str, Any]]] = {}
for sym, last_close in (("AAPL", 120.0), ("MSFT", 100.0), ("GOOG", 120.0)):
bars2.update(_build_bars(
sym, prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1_000_000.0,
last_close=last_close,
last_high=last_close + 1.0, last_low=last_close - 1.0,
last_volume=4_000_000.0,
))
after = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["AAPL", "MSFT", "GOOG"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=bars2),
pre_open_gap_provider=None,
)
assert sorted(c.symbol for c in after) == ["AAPL", "GOOG"]
# ---------------------------------------------------------------------------
# Universe filter
# ---------------------------------------------------------------------------
def test_universe_filter_skips_low_adv_symbol():
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
# Tiny ADV: low_volume * close = small.
low_adv = _build_bars("TINY", prior_days,
base_close=100.0, base_high=102.0, base_low=98.0,
base_volume=1000.0, # ~$100k ADV
last_close=120.0, last_high=121.0, last_low=119.0,
last_volume=4000.0)
engine = _make_engine(vol_breakout_52w_min_avg_dollar_volume=10_000_000.0)
cands = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["TINY"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=low_adv),
pre_open_gap_provider=None,
)
assert cands == []
def test_universe_filter_skips_low_price_symbol():
days = _generate_business_days(dt.date(2026, 1, 5), 82)
decision_date = days[80]
next_trading_date = days[81]
prior_days = days[:80]
bars = _build_bars("PENNY", prior_days,
base_close=2.0, base_high=2.2, base_low=1.8,
base_volume=10_000_000.0,
last_close=3.0, # below $5 floor
last_high=3.1, last_low=2.9,
last_volume=40_000_000.0)
engine = _make_engine(vol_breakout_52w_min_price=5.0)
cands = build_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
universe_symbols=["PENNY"],
engine=engine,
bar_provider=_SnapshotStoreBarAdapter(bars_by_symbol=bars),
pre_open_gap_provider=None,
)
assert cands == []
# ---------------------------------------------------------------------------
# Behavioral exit tests — drive a synthetic position through simulate_exit
# ---------------------------------------------------------------------------
def _build_position_for_breakout(
*,
entry_price: float = 100.0,
stop_pct: float = 0.03,
target_pct: float = 0.05,
days_held: int = 0,
):
from libs.backtest.domain import Candidate, OpenPosition, PlannedOrder
stop_mult = stop_pct / 0.02
target_r = target_pct / stop_pct
synthetic_atr = entry_price * 0.02
cand = Candidate(
event_id="evt_volb_exit",
symbol="AAPL",
score=0.7,
sector="UNKNOWN",
event_type=VOL_BREAKOUT_52W_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="vol_breakout_52w_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=2,
)
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_volb",
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(max_hold: int = 2):
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=max_hold,
)
def test_exit_stop_at_minus_3pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, stop_pct=0.03)
bar = {"date": dt.date(2026, 4, 15), "open": 99.0, "high": 99.5, "low": 96.5,
"close": 97.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.STOP
def test_exit_target_at_plus_5pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, target_pct=0.05)
bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 105.5, "low": 101.0,
"close": 104.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_at_day_2_moc():
"""When days_held >= max_holding_days=2 and no stop/target hit, exit reason is TIME."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, days_held=2)
cfg = _exec_config_for_exit_test(max_hold=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_intraday_priority_stop_before_target_when_both_touched():
"""Same-bar priority: stop_first_conservative — stop wins when both lines touched."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_breakout(entry_price=100.0, stop_pct=0.03, target_pct=0.05)
# Wide bar that touches both 97.0 (stop) AND 105.0 (target).
bar = {"date": dt.date(2026, 4, 15), "open": 99.5, "high": 105.5, "low": 96.5,
"close": 100.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.STOP