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.

984 lines
37 KiB
Python

"""Unit tests for the PeerSympathy engine."""
from __future__ import annotations
import datetime as dt
import math
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 _PitCalendarUpcomingEarningsAdapter
from libs.backtest.peer_sympathy import (
PEER_SYMPATHY_EVENT_TYPE,
LeaderPrint,
PeerSympathyTriggerInputs,
_assert_correlation_window_safe,
build_peer_sympathy_candidates,
compute_correlation,
evaluate_trigger,
)
# ---------------------------------------------------------------------------
# Fakes & helpers
# ---------------------------------------------------------------------------
class _FakeBarHistory:
"""In-memory BarHistoryProvider stub.
``bars`` is keyed by symbol → {date: bar_dict}. ``get_bars_before`` returns
the chronologically-ordered subset strictly before ``as_of_date``.
"""
def __init__(self, bars: dict[str, dict[dt.date, dict[str, Any]]]) -> None:
self.bars = {k.upper(): dict(v) for k, v in bars.items()}
def get_bars_before(
self,
symbol: str,
as_of_date: dt.date,
lookback_days: int,
) -> list[tuple[dt.date, dict[str, Any]]]:
sym_bars = self.bars.get(symbol.upper())
if not sym_bars:
return []
eligible = sorted(
(d, sym_bars[d]) for d in sym_bars if d < as_of_date
)
return eligible[-lookback_days:]
class _StaticPeerResolver:
def __init__(self, peers_by_leader: dict[str, list[str]]) -> None:
self.peers_by_leader = {k.upper(): list(v) for k, v in peers_by_leader.items()}
def peers_for_leader(
self,
engine: StrategyEngineConfig,
leader_symbol: str,
leader_sector: str,
) -> list[str]:
return self.peers_by_leader.get(leader_symbol.upper(), [])
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 _make_engine(**overrides: Any) -> StrategyEngineConfig:
base: dict[str, Any] = dict(
engine_id="peer_sympathy_long",
event_types=[PEER_SYMPATHY_EVENT_TYPE],
direction="long_only",
timing_class="after_close",
entry_timing_policy="next_open",
peer_sympathy_enabled=True,
peer_sympathy_leader_event_types=["earnings_release", "guidance_update", "material_contract"],
peer_sympathy_leader_reaction_min=0.05,
peer_sympathy_correlation_min=0.55,
peer_sympathy_correlation_window_start=65,
peer_sympathy_correlation_window_end_skip=5,
peer_sympathy_top_n_peers=2,
peer_sympathy_blackout_days_to_peer_event=3,
peer_sympathy_stop_pct=0.035,
peer_sympathy_target_pct=0.06,
peer_sympathy_max_holding_days=3,
)
base.update(overrides)
return StrategyEngineConfig(**base)
def _build_correlated_series(
leader_symbol: str,
peer_symbol: str,
trading_days: list[dt.date],
*,
correlation: float,
base: float = 100.0,
seed: int = 0,
) -> dict[str, dict[dt.date, dict[str, Any]]]:
"""Construct two synthetic price series with approximate ``correlation`` between
their consecutive log-returns.
peer_return[t] = correlation * leader_return[t] + sqrt(1-rho^2) * noise[t]
"""
import random
rng = random.Random(seed)
leader_returns = [rng.gauss(0.001, 0.015) for _ in trading_days]
noise = [rng.gauss(0, 0.015) for _ in trading_days]
leader_closes: list[float] = [base]
peer_closes: list[float] = [base]
rho = float(correlation)
sqrt_term = math.sqrt(max(0.0, 1.0 - rho * rho))
for i in range(1, len(trading_days)):
l_ret = leader_returns[i]
p_ret = rho * l_ret + sqrt_term * noise[i]
leader_closes.append(leader_closes[-1] * math.exp(l_ret))
peer_closes.append(peer_closes[-1] * math.exp(p_ret))
bars: dict[str, dict[dt.date, dict[str, Any]]] = {leader_symbol: {}, peer_symbol: {}}
for i, d in enumerate(trading_days):
bars[leader_symbol][d] = {
"open": leader_closes[i], "high": leader_closes[i] * 1.01,
"low": leader_closes[i] * 0.99, "close": leader_closes[i],
"volume": 1_000_000.0,
}
bars[peer_symbol][d] = {
"open": peer_closes[i], "high": peer_closes[i] * 1.01,
"low": peer_closes[i] * 0.99, "close": peer_closes[i],
"volume": 2_000_000.0,
}
return bars
def _trigger_inputs(**overrides: Any) -> PeerSympathyTriggerInputs:
base: dict[str, Any] = dict(
leader_symbol="NVDA",
leader_sector="Technology",
leader_event_type="earnings_release",
leader_reaction=0.08,
peer_symbol="AVGO",
decision_date=dt.date(2026, 4, 13),
next_trading_date=dt.date(2026, 4, 14),
correlation=0.72,
peer_last_close=110.0,
peer_avg_dollar_volume_20d=300_000_000.0,
peer_last_bar_date=dt.date(2026, 4, 10),
peer_last_bar_timestamp=dt.datetime(2026, 4, 10, 21, 0, tzinfo=dt.timezone.utc),
peer_upcoming_earnings_reaction_date=None,
peer_trading_days_to_own_earnings=None,
)
base.update(overrides)
return PeerSympathyTriggerInputs(**base)
# ---------------------------------------------------------------------------
# evaluate_trigger() — happy path + 3 negative + blackout
# ---------------------------------------------------------------------------
def test_trigger_fires_when_all_conditions_met():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(), engine)
assert passes is True, reason
def test_trigger_blocks_when_event_type_not_qualifying():
engine = _make_engine()
passes, reason = evaluate_trigger(
_trigger_inputs(leader_event_type="other_material_event"),
engine,
)
assert passes is False
assert "leader_event_type" in (reason or "")
def test_trigger_blocks_when_leader_reaction_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(leader_reaction=0.03), engine)
assert passes is False
assert "leader_reaction" in (reason or "")
def test_trigger_blocks_when_correlation_below_min():
engine = _make_engine()
passes, reason = evaluate_trigger(_trigger_inputs(correlation=0.50), engine)
assert passes is False
assert "correlation" in (reason or "")
def test_trigger_blocks_on_peer_earnings_blackout():
engine = _make_engine()
passes, reason = evaluate_trigger(
_trigger_inputs(peer_trading_days_to_own_earnings=2),
engine,
)
assert passes is False
assert "blackout" in (reason or "") or "earnings" in (reason or "")
# ---------------------------------------------------------------------------
# compute_correlation() — basic invariants
# ---------------------------------------------------------------------------
def test_compute_correlation_returns_high_value_for_correlated_series():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 100)
decision_date = trading_days[-1]
bars = _build_correlated_series("NVDA", "AVGO", trading_days[:-1], correlation=0.85, seed=1)
leader_bars = sorted((d, b) for d, b in bars["NVDA"].items())
peer_bars = sorted((d, b) for d, b in bars["AVGO"].items())
rho, used = compute_correlation(
leader_bars,
peer_bars,
decision_date=decision_date,
window_start=65,
window_end_skip=5,
trading_days=trading_days,
)
assert rho is not None
assert rho > 0.6 # roughly tracks the imposed correlation
assert used # non-empty
def test_compute_correlation_skips_last_n_days():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 100)
decision_date = trading_days[-1]
bars = _build_correlated_series("NVDA", "AVGO", trading_days[:-1], correlation=0.85, seed=2)
leader_bars = sorted((d, b) for d, b in bars["NVDA"].items())
peer_bars = sorted((d, b) for d, b in bars["AVGO"].items())
_, used = compute_correlation(
leader_bars,
peer_bars,
decision_date=decision_date,
window_start=65,
window_end_skip=5,
trading_days=trading_days,
)
assert used
skip_idx = trading_days.index(decision_date) - 5
forbidden_floor = trading_days[skip_idx]
assert max(used) < forbidden_floor
def test_assert_correlation_window_safe_raises_when_recent_date_used():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 80)
decision_date = trading_days[-1]
# used_dates includes a date from within the skip window (T-2)
leaky_date = trading_days[-3]
with pytest.raises(LookaheadViolationError):
_assert_correlation_window_safe(
leader_symbol="NVDA",
peer_symbol="AVGO",
decision_date=decision_date,
window_end_skip=5,
used_dates=[leaky_date],
trading_days=trading_days,
)
def test_assert_correlation_window_safe_accepts_safely_old_dates():
trading_days = _generate_business_days(dt.date(2026, 1, 5), 80)
decision_date = trading_days[-1]
safe_date = trading_days[-20]
# Should not raise.
_assert_correlation_window_safe(
leader_symbol="NVDA",
peer_symbol="AVGO",
decision_date=decision_date,
window_end_skip=5,
used_dates=[safe_date],
trading_days=trading_days,
)
# ---------------------------------------------------------------------------
# build_peer_sympathy_candidates() — end-to-end
# ---------------------------------------------------------------------------
def _build_full_setup(
*,
correlation: float = 0.85,
leader_reaction: float = 0.08,
leader_event_type: str = "earnings_release",
peer_symbol: str = "AVGO",
days_of_history: int = 90,
):
# Pad the calendar with extra trailing days so PIT-calendar tests can place
# peer earnings dates AFTER ``next_trading_date`` without IndexError.
trading_days = _generate_business_days(dt.date(2026, 1, 5), days_of_history + 15)
decision_date = trading_days[days_of_history] # leader event day = T
next_trading_date = trading_days[days_of_history + 1]
history_days = trading_days[:days_of_history]
bars = _build_correlated_series(
"NVDA", peer_symbol, history_days, correlation=correlation, seed=11
)
bar_provider = _FakeBarHistory(bars)
peer_resolver = _StaticPeerResolver({"NVDA": [peer_symbol]})
leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt_nvda_2026q1",
event_type=leader_event_type,
event_date=decision_date,
event_timestamp=dt.datetime.combine(
decision_date, dt.time(16, 0), tzinfo=dt.timezone.utc
),
reaction_day_return=leader_reaction,
score=0.85,
)
return {
"trading_days": trading_days,
"decision_date": decision_date,
"next_trading_date": next_trading_date,
"leader": leader,
"peer_symbol": peer_symbol,
"bar_provider": bar_provider,
"peer_resolver": peer_resolver,
}
def test_build_emits_candidate_for_correlated_peer():
setup = _build_full_setup(correlation=0.90)
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert len(cands) == 1
cand = cands[0]
assert cand.event_type == PEER_SYMPATHY_EVENT_TYPE
assert cand.symbol == setup["peer_symbol"]
assert cand.source_symbol == "NVDA"
assert cand.engine_id == engine.engine_id
assert cand.execution_date == setup["next_trading_date"]
assert cand.engine_max_holding_days is not None
assert cand.features["peer_sympathy_leader_symbol"] == "NVDA"
assert cand.features["peer_sympathy_correlation"] >= 0.55
def test_build_skips_uncorrelated_peer():
setup = _build_full_setup(correlation=0.10)
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert cands == []
def test_build_skips_when_leader_reaction_below_min():
setup = _build_full_setup(correlation=0.90, leader_reaction=0.02)
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert cands == []
def test_build_skips_when_event_type_not_qualifying():
setup = _build_full_setup(correlation=0.90, leader_event_type="other_material_event")
engine = _make_engine()
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert cands == []
def test_build_takes_top_n_peers_only():
"""With 3 peers (corr 0.95, 0.75, 0.40), top_n=2 → only first two emitted."""
days_of_history = 90
trading_days = _generate_business_days(dt.date(2026, 1, 5), days_of_history + 2)
decision_date = trading_days[days_of_history]
next_trading_date = trading_days[days_of_history + 1]
history_days = trading_days[:days_of_history]
# Build leader and 3 peers with controlled correlation.
bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
for peer, rho, seed in [("AVGO", 0.95, 100), ("AMD", 0.75, 200), ("MU", 0.40, 300)]:
sub = _build_correlated_series("NVDA", peer, history_days, correlation=rho, seed=seed)
# Only NVDA appears once — re-use leader from first iteration.
if "NVDA" not in bars:
bars["NVDA"] = sub["NVDA"]
bars[peer] = sub[peer]
bar_provider = _FakeBarHistory(bars)
peer_resolver = _StaticPeerResolver({"NVDA": ["AVGO", "AMD", "MU"]})
leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=decision_date,
event_timestamp=dt.datetime.combine(decision_date, dt.time(16, 0), tzinfo=dt.timezone.utc),
reaction_day_return=0.08,
)
engine = _make_engine(peer_sympathy_top_n_peers=2)
cands = build_peer_sympathy_candidates(
decision_date=decision_date,
next_trading_date=next_trading_date,
leaders=[leader],
peer_resolver=peer_resolver,
engine=engine,
bar_provider=bar_provider,
trading_days=trading_days,
)
# MU (0.40) is below correlation_min anyway; AVGO + AMD survive.
assert len(cands) <= 2
chosen = {c.symbol for c in cands}
assert "MU" not in chosen
# ---------------------------------------------------------------------------
# Look-ahead defenses
# ---------------------------------------------------------------------------
def test_build_raises_when_next_trading_date_not_after_decision():
setup = _build_full_setup()
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["decision_date"], # equal → violation
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
def test_build_raises_when_leader_event_timestamp_naive():
setup = _build_full_setup()
bad_leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=setup["decision_date"],
event_timestamp=dt.datetime.combine(setup["decision_date"], dt.time(16, 0)), # naive
reaction_day_return=0.08,
)
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[bad_leader],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
def test_build_raises_when_leader_event_timestamp_after_peer_open():
"""Leader event timestamp at-or-after T+1 09:30 ET cutoff is a look-ahead violation."""
setup = _build_full_setup()
# 09:30 ET on next_trading_date == 14:30 UTC under EST.
leak_ts = dt.datetime.combine(setup["next_trading_date"], dt.time(15, 0), tzinfo=dt.timezone.utc)
leak_leader = LeaderPrint(
symbol="NVDA",
sector="Technology",
event_id="evt",
event_type="earnings_release",
event_date=setup["decision_date"],
event_timestamp=leak_ts,
reaction_day_return=0.08,
)
engine = _make_engine()
with pytest.raises(LookaheadViolationError):
build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[leak_leader],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
def test_build_does_not_consult_peer_t0_reaction():
"""Bars dated == decision_date (peer T+0) must NOT enter selection.
Concrete check: inject a peer bar dated ON decision_date with an extreme
return; if the engine were reading T+0 it would either crash on look-ahead
(preferred) or produce a different correlation. We use a permissive bar
provider that lets ``< as_of`` filter run; the engine must produce a
candidate consistent with PRE-T data alone.
"""
setup = _build_full_setup(correlation=0.90)
bars = setup["bar_provider"].bars
# Inject a wild peer bar on decision_date (not strictly before).
bars[setup["peer_symbol"]][setup["decision_date"]] = {
"open": 50.0, "high": 50.0, "low": 50.0, "close": 50.0, "volume": 99_000_000.0,
}
engine = _make_engine()
# The default _FakeBarHistory.get_bars_before filters strictly < decision_date,
# so the injected T+0 bar is not visible. Candidate features must therefore
# NOT reference any T+0 quantity. We assert by snapshotting the candidate
# features and confirming the only price reference is the LAST bar < T.
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert len(cands) == 1
cand = cands[0]
# entry_price_est must equal the last close strictly BEFORE decision_date.
last_pre_t = max(d for d in bars[setup["peer_symbol"]] if d < setup["decision_date"])
expected_close = float(bars[setup["peer_symbol"]][last_pre_t]["close"])
assert math.isclose(cand.entry_price_est, expected_close, rel_tol=1e-6)
# ---------------------------------------------------------------------------
# Peer-resolver integration with existing leader-follower infra
# ---------------------------------------------------------------------------
def test_peer_set_sourced_from_leader_follower_extra_peer_symbols_by_sector():
"""Engine config's leader_follower_extra_peer_symbols_by_sector must surface peers."""
from libs.backtest.proxies import peer_candidates_for_symbol
# Sanity: the underlying helper recognizes NVDA → has tech peers from the curated map.
peers = peer_candidates_for_symbol("NVDA", "Technology")
assert "AVGO" in peers
assert "AMD" in peers
def test_peer_set_extra_by_sector_is_consumed_by_resolver_protocol():
"""The runner's _RunnerPeerResolver wraps the existing _leader_follower_peer_candidates;
the static fake must equally honor curated peers by sector."""
resolver = _StaticPeerResolver({"NVDA": ["AVGO", "AMD"]})
engine = _make_engine(
leader_follower_extra_peer_symbols_by_sector={"Technology": ["MU"]}
)
peers = resolver.peers_for_leader(engine, "NVDA", "Technology")
# Static fake returns the supplied list; this confirms the Protocol shape.
assert peers == ["AVGO", "AMD"]
# ---------------------------------------------------------------------------
# Exit policy stub tests — verify candidate carries correct exit configuration
# ---------------------------------------------------------------------------
def test_candidate_carries_stop_target_max_hold_in_engine_overrides():
setup = _build_full_setup(correlation=0.90)
engine = _make_engine(
peer_sympathy_stop_pct=0.035,
peer_sympathy_target_pct=0.06,
peer_sympathy_max_holding_days=3,
)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
trading_days=setup["trading_days"],
)
assert len(cands) == 1
cand = cands[0]
# stop_pct 0.035 / 0.02 = 1.75 ATR multiplier
assert math.isclose(cand.engine_stop_atr_multiplier, 1.75, rel_tol=1e-6)
# target_pct / stop_pct = 0.06 / 0.035 = 1.714...
assert math.isclose(cand.engine_target_1_r, 0.06 / 0.035, rel_tol=1e-6)
assert cand.engine_target_1_fraction == 1.0
assert cand.engine_max_holding_days == 3
def test_candidate_max_hold_capped_by_peer_earnings_blackout():
"""If peer's own earnings are 4 trading days out and blackout=3 → max_hold = max(1, 4-3) = 1."""
setup = _build_full_setup(correlation=0.90)
# Build a PIT calendar: peer has earnings 4 trading days after next_trading_date.
trading_days = setup["trading_days"]
next_idx = trading_days.index(setup["next_trading_date"])
peer_earnings_date = trading_days[next_idx + 4]
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol=setup["peer_symbol"],
as_of_date=trading_days[0],
expected_reaction_date=peer_earnings_date,
expected_event_date=peer_earnings_date,
filing_time_bucket="post_market",
)
]
)
upcoming = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
engine = _make_engine(
peer_sympathy_blackout_days_to_peer_event=3,
peer_sympathy_max_holding_days=3,
)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
upcoming_earnings_provider=upcoming,
trading_days=trading_days,
)
# 4 days to event > blackout 3 → not blocked. Hold = max(1, 4-3) = 1.
assert len(cands) == 1
assert cands[0].engine_max_holding_days == 1
def test_candidate_blocked_when_peer_earnings_within_blackout_window():
"""Peer earnings 2 trading days out, blackout=3 → trigger BLOCKS (no candidate)."""
setup = _build_full_setup(correlation=0.90)
trading_days = setup["trading_days"]
next_idx = trading_days.index(setup["next_trading_date"])
peer_earnings_date = trading_days[next_idx + 2] # 2 trading days out
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol=setup["peer_symbol"],
as_of_date=trading_days[0],
expected_reaction_date=peer_earnings_date,
expected_event_date=peer_earnings_date,
filing_time_bucket="post_market",
)
]
)
upcoming = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
engine = _make_engine(peer_sympathy_blackout_days_to_peer_event=3)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
upcoming_earnings_provider=upcoming,
trading_days=trading_days,
)
assert cands == []
# ---------------------------------------------------------------------------
# Behavioral exit tests — drive synthetic position through simulate_exit and
# confirm pct exits map correctly to STOP / TARGET / TIME outcomes.
# ---------------------------------------------------------------------------
def _build_position_for_peer_sympathy(
*,
entry_price: float = 100.0,
stop_pct: float = 0.035,
target_pct: float = 0.06,
days_held: int = 0,
) -> Any:
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_peer_sympathy",
symbol="AVGO",
source_symbol="NVDA",
score=0.8,
sector="Technology",
event_type=PEER_SYMPATHY_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=300_000_000.0,
atr_14=synthetic_atr,
score_bucket="high",
engine_id="peer_sympathy_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=3,
)
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_peer",
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=3,
)
def test_exit_stop_at_minus_3_5_pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_peer_sympathy(entry_price=100.0, stop_pct=0.035)
# Bar drops to 96.0 < 96.5 stop → STOP.
bar = {"date": dt.date(2026, 4, 15), "open": 99.0, "high": 99.5, "low": 96.0, "close": 96.7, "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_6_pct():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
pos = _build_position_for_peer_sympathy(entry_price=100.0, target_pct=0.06)
# Bar high reaches 106.5 > target 106.0 → TARGET.
bar = {"date": dt.date(2026, 4, 15), "open": 102.0, "high": 106.5, "low": 101.0, "close": 105.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.TARGET
def test_exit_time_at_max_holding_days():
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit
cfg = _exec_config_for_exit_test().model_copy(update={"max_holding_days": 3})
pos = _build_position_for_peer_sympathy(entry_price=100.0, days_held=3)
bar = {"date": dt.date(2026, 4, 17), "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, 17))
assert trade is not None
assert trade.exit_reason == ExitReason.TIME
def test_exit_blackout_caps_max_hold_via_engine_max_holding_days():
"""When peer's own earnings are within blackout window, candidate's
engine_max_holding_days is capped to (days_to_event - blackout) ≥ 1.
The execution machinery then treats this as the effective hold ceiling."""
setup = _build_full_setup(correlation=0.90)
trading_days = setup["trading_days"]
next_idx = trading_days.index(setup["next_trading_date"])
peer_earnings_date = trading_days[next_idx + 5]
pit_calendar = PointInTimeEarningsCalendar(
[
EarningsCalendarEntry(
symbol=setup["peer_symbol"],
as_of_date=trading_days[0],
expected_reaction_date=peer_earnings_date,
expected_event_date=peer_earnings_date,
filing_time_bucket="post_market",
)
]
)
upcoming = _PitCalendarUpcomingEarningsAdapter(
pit_calendar=pit_calendar,
trading_days=trading_days,
)
engine = _make_engine(
peer_sympathy_blackout_days_to_peer_event=3,
peer_sympathy_max_holding_days=3,
)
cands = build_peer_sympathy_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
leaders=[setup["leader"]],
peer_resolver=setup["peer_resolver"],
engine=engine,
bar_provider=setup["bar_provider"],
upcoming_earnings_provider=upcoming,
trading_days=trading_days,
)
assert len(cands) == 1
# 5 days to event - blackout 3 = 2; min(default_max_hold=3, 2) = 2.
assert cands[0].engine_max_holding_days == 2
# ---------------------------------------------------------------------------
# Regression: runner-adapter select_candidates must NOT pass the peer_sympathy
# strategy_engine, because that engine declares event_types=['peer_sympathy']
# (a synthetic downstream type) which would filter out every real leader row
# (earnings_release / guidance_update / material_contract). This is the bug
# that produced 0 trades over 1051 days in PoC v1.
# ---------------------------------------------------------------------------
def _make_leader_raw_row(**overrides: Any) -> dict[str, Any]:
"""Minimal real-shape PEAD candidate row representing a leader print."""
base: dict[str, Any] = {
"event_id": "EVT::NVDA::2024-02-22",
"symbol": "NVDA",
"issuer_id": "ISSUER::0001045810",
"score": 0.85,
"sector": "Technology",
"event_type": "earnings_release",
"event_timestamp": "2024-02-21T21:00:00+00:00",
"filing_time_bucket": "post_market",
"entry_convention": "next_open_after_reaction_close",
"reaction_date": "2024-02-22",
"entry_date": "2024-02-23",
"entry_price": 730.0,
"avg_dollar_volume": 25_000_000_000.0,
"avg_dollar_volume_20d": 25_000_000_000.0,
"atr_14": 25.0,
"reaction_day_return": 0.164,
"reaction_day_open": 680.0,
"reaction_day_close": 791.0,
"reaction_day_high": 800.0,
"reaction_day_low": 670.0,
"exchange_proxy": "NASDAQ",
"volume_ratio_20d": 3.5,
"gap_size": 0.10,
}
base.update(overrides)
return base
def test_select_candidates_with_peer_sympathy_engine_drops_real_leaders():
"""Demonstrates the bug: passing the peer_sympathy engine to select_candidates
drops every real leader print, because the engine's event_types=['peer_sympathy']
does not include 'earnings_release' etc.
This test pins down the unsafe interaction so a future dev cannot silently
re-introduce ``strategy_engine=engine`` in ``_schedule_peer_sympathy_candidates``
without it failing here.
"""
from libs.backtest.domain import SignalConfig, UniverseConfig
from libs.backtest.peer_sympathy import PEER_SYMPATHY_EVENT_TYPE
from libs.backtest.selector import select_candidates
rows = [
_make_leader_raw_row(symbol="NVDA", event_type="earnings_release", reaction_day_return=0.164),
_make_leader_raw_row(symbol="MRNA", event_type="earnings_release", reaction_day_return=0.135,
event_id="EVT::MRNA::2024-02-22", issuer_id="ISSUER::0001682852"),
_make_leader_raw_row(symbol="MU", event_type="guidance_update", reaction_day_return=0.086,
event_id="EVT::MU::2023-12-21", issuer_id="ISSUER::0000723125"),
]
universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
signal = SignalConfig(
scoring_model="return_max_long_v13e",
score_threshold=0.0,
max_candidates_per_day=18,
)
peer_sympathy_engine = _make_engine(
event_types=[PEER_SYMPATHY_EVENT_TYPE],
score_threshold_override=0.0,
)
# Bug reproduction: engine event_types filter rejects all real leader rows.
selected_with_engine = select_candidates(
rows,
universe,
signal,
strategy_engine=peer_sympathy_engine,
truncate_to=90,
)
assert selected_with_engine == [], (
"Bug regression: select_candidates with peer_sympathy strategy_engine "
"must drop real leader rows because their event_type ('earnings_release', "
"'guidance_update') is not in the engine's event_types=['peer_sympathy']. "
"If this assertion stops holding, the runner adapter contract has shifted "
"and the no-engine call in _schedule_peer_sympathy_candidates may need "
"to be revisited."
)
def test_select_candidates_without_engine_retains_real_leaders():
"""The fixed runner-adapter call path: ``select_candidates`` is invoked WITHOUT
the peer_sympathy strategy_engine, so real leader rows are retained and can
feed the manual peer_sympathy_leader_event_types filter downstream.
"""
from libs.backtest.domain import SignalConfig, UniverseConfig
from libs.backtest.selector import select_candidates
rows = [
_make_leader_raw_row(symbol="NVDA", event_type="earnings_release", reaction_day_return=0.164),
_make_leader_raw_row(symbol="MRNA", event_type="earnings_release", reaction_day_return=0.135,
event_id="EVT::MRNA::2024-02-22", issuer_id="ISSUER::0001682852"),
_make_leader_raw_row(symbol="MU", event_type="guidance_update", reaction_day_return=0.086,
event_id="EVT::MU::2023-12-21", issuer_id="ISSUER::0000723125"),
]
universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
signal = SignalConfig(
scoring_model="return_max_long_v13e",
score_threshold=0.0,
max_candidates_per_day=18,
)
# Fix: NO strategy_engine kwarg. Real leader rows survive selection.
selected = select_candidates(
rows,
universe,
signal,
truncate_to=90,
)
selected_symbols = {c.symbol.upper() for c in selected}
assert "NVDA" in selected_symbols
assert "MRNA" in selected_symbols
assert "MU" in selected_symbols, (
"Fix regression: select_candidates without strategy_engine must retain "
"real leader rows so that _schedule_peer_sympathy_candidates can apply "
"its manual peer_sympathy_leader_event_types filter and emit synthetic "
"peer candidates. If this fails, the runner adapter is once again "
"starving downstream peer-sympathy logic."
)