Add 3 candidate engine classes beyond PEAD: EarningsRunup, PeerSympathy, VolBreakout52w
Adds three new synthetic-Candidate emitter engines parallel to the existing leader_follower scheduler hook, plus look-ahead defenses (LookaheadViolationError + per-engine assertions). Each engine is covered by a standalone PoC config (no PEAD/parking/idle alpha) for isolation backtests against the midlarge or broad snapshot. Engines: EarningsRunup (libs/backtest/earnings_runup.py) - Trigger: days_to_earnings ∈ [3,7] AND attention_zscore_20d ≥ 1.5 AND dollar_volume_20d_zscore ≥ 1.0 (all evaluated at T-1 close) - Entry: T+1 next_open. Exit: -4% / +8% / max_holding_days = days_to_earnings - buffer (forced flat by close before announcement) - PIT calendar: PointInTimeEarningsCalendar adapter for backtest; oracle_surprise_prefetch fallback when parquet calendar absent - PoC verdict (configs/experiments/earnings_runup_poc_v1.json): 119 trades over 1051 days, +37.27% total return, 44.46% MDD, SQS 45.2 (profitability=55.5, risk=23.5, robustness=50.1). VIABLE BUT NEEDS WORK — signal exists; standalone risk profile too aggressive for v7.356 baseline (8.8% MDD on v7.364). Path forward: per_trade_risk reduction, VIX gate, position cap, or integrate as PEAD sleeve adjunct (not as standalone replacement). PeerSympathy (libs/backtest/peer_sympathy.py) - Trigger: leader passes PEAD filter (earnings_release / guidance_update / material_contract) AND leader reaction_close ≥ +5% AND peer 60d correlation ≥ 0.55 over [T-65, T-5]. Top-2 peers by correlation from leader_follower_extra_peer_symbols_by_sector + sector ETF holdings. - Entry: T+1 next_open on peer. Exit: -3.5% / +6% / max_holding=3 / peer-earnings blackout - PoC verdict (configs/experiments/peer_sympathy_poc_v1.json): 256 trades over 1051 days, -52.92% total return, 54.47% MDD, SQS 19.6 (profitability=0.0, risk=5.4, robustness=100.0). DEAD. The leader's catalyst is already absorbed by T+1 next_open — peers gap up overnight before entry. robustness=100 confirms the negative result is not noise. Salvage paths (not implemented): reaction_close entry, raised-guidance-only restriction. - Note: initial run_id was 0 trades due to a select_candidates filter mismatch (engine.event_types=['peer_sympathy'] dropping real event_type='earnings_release' rows). The runner adapter was patched to bypass strategy_engine filtering for leader selection; the manual peer_sympathy_leader_event_types filter does the gating. VolBreakout52w (libs/backtest/vol_breakout_52w.py) - Trigger: close_T-1 > max(high[T-252:T-2]) AND volume_T-1 ≥ 2 × median_volume_20d_T-2 AND ATR_14_T-1/close ∈ [0.015, 0.06]. Entry T next_open, exit -3% / +5% / max_holding=2 / MOC. - Honest, look-ahead-safe descendant of the retired topgainer v1-v54 family. Five layers of strict-before assertions guard the bar provider, candidate construction, trigger evaluation, and feature timestamps. A leaky-provider proof-by-contradiction test demonstrates the categorical catch. - PoC verdict (configs/experiments/vol_breakout_52w_poc_v1.json, broad-liquid universe): 1,332 trades, -87.28% total return, 88.74% MDD, SQS 24.4 (profitability=0.0, robustness=100.0). DEAD AND HONEST. This is the most important finding of the three PoCs: the topgainer v1-v54 lineage's headline returns (+267% Sharpe 13.73 in best variants) were 100% lookahead bug. With the bug removed, the 52w-high + volume + ATR signal has no real alpha — the lookahead-corrected -4.3% from prior memory is confirmed and amplified to -87% on a fuller universe and longer horizon. Future "revive topgainer" proposals can cite this run (bt_return_max_long_v1_broad-liquid_20260509042903892342_3bb473d9) as definitive falsification. - Pre-open gap guard inactive (no premarket data in broad snapshot). skip_if_no_gap_data=true; the +4% gap-fade guard would not move the result given the magnitude. Shared infrastructure additions: - libs/backtest/domain.py: LookaheadViolationError class + StrategyEngineConfig fields (11 EarningsRunup + 11 PeerSympathy + 13 VolBreakout52w = 35 new fields) - apps/backtester/run.py: _BacktestAttentionZscoreAdapter, _RunnerPeerResolver, _schedule_earnings_runup_candidates, _schedule_peer_sympathy_candidates, _schedule_vol_breakout_52w_candidates wired into the daily scheduler block. PeerSympathy adapter bypasses strategy_engine filtering on leader selection (manual filter handles gating). Tests: 21 (EarningsRunup) + 27 (PeerSympathy) + 38 (VolBreakout52w) = 86 new unit tests, all passing. Broader unit suite: 1392 passed, 2 pre-existing failures unrelated. Net engine state: EarningsRunup is the only viable new engine class. PeerSympathy and VolBreakout52w are kept in-tree as falsification evidence, not as production engines. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>main
parent
f67c534ce3
commit
956cc78f1b
@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"experiment_name": "earnings_runup_poc_v1",
|
||||||
|
"dataset_snapshot_id": "midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2",
|
||||||
|
"description": "Standalone EarningsRunup engine isolation backtest. Buys 3-7 trading days before scheduled earnings when attention z-score >= 1.5 AND dollar-volume z-score >= 1.0; exits before the print. No PEAD engines, no parking, no idle alpha — pure standalone EV measurement.",
|
||||||
|
"base_config": "configs/backtest/return_max_long_v1.json",
|
||||||
|
"overrides": {
|
||||||
|
"signal": {
|
||||||
|
"scoring_model": "return_max_long_v13e",
|
||||||
|
"score_threshold": 0.0,
|
||||||
|
"max_candidates_per_day": 18,
|
||||||
|
"a_tier_score_threshold": 0.99
|
||||||
|
},
|
||||||
|
"risk": {
|
||||||
|
"per_trade_risk_pct": 0.65,
|
||||||
|
"per_trade_risk_pct_a_tier": 0.65,
|
||||||
|
"max_daily_new_risk_pct": 50,
|
||||||
|
"max_positions": 30,
|
||||||
|
"max_positions_per_sector": 30,
|
||||||
|
"max_position_value_pct": 25,
|
||||||
|
"max_adv_fraction": 0.3,
|
||||||
|
"macro_regime_neutral_size_scaler": 1,
|
||||||
|
"macro_regime_risk_off_size_scaler": 1,
|
||||||
|
"veto_unknown_direction": false,
|
||||||
|
"veto_bearish_direction": false,
|
||||||
|
"macro_regime_risk_off_a_tier_only": false,
|
||||||
|
"stop_atr_multiplier": 3,
|
||||||
|
"allow_budget_downsizing": true,
|
||||||
|
"cash_parking_preset": null,
|
||||||
|
"fixed_capital_sizing": false
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"trailing_warmup_days": 7,
|
||||||
|
"max_holding_days": 7,
|
||||||
|
"early_failure_no_progress_days": 1,
|
||||||
|
"early_failure_no_progress_r": 0.0,
|
||||||
|
"early_failure_no_progress_fraction": 0,
|
||||||
|
"lookback_entry_enabled": false
|
||||||
|
},
|
||||||
|
"event_type_profiles": {
|
||||||
|
"earnings_runup_preevent": {
|
||||||
|
"enabled": true,
|
||||||
|
"direction_filter": "any",
|
||||||
|
"max_holding_days_override": 7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idle_alpha_sleeve_preset": null,
|
||||||
|
"form4_capture_sleeve_preset": null,
|
||||||
|
"ownership_capture_sleeve_preset": null,
|
||||||
|
"risk_off_alpha_sleeve_preset": null,
|
||||||
|
"dividend_capture_sleeve_preset": null
|
||||||
|
},
|
||||||
|
"strategy_engines": [
|
||||||
|
{
|
||||||
|
"engine_id": "earnings_runup_preevent_long",
|
||||||
|
"event_types": ["earnings_runup_preevent"],
|
||||||
|
"timing_class": "after_close",
|
||||||
|
"direction": "long_only",
|
||||||
|
"entry_timing_policy": "next_open",
|
||||||
|
"engine_risk_budget_pct": 1.0,
|
||||||
|
"score_threshold_override": 0.0,
|
||||||
|
"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_min_avg_dollar_volume": 50000000.0,
|
||||||
|
"earnings_runup_stop_pct": 0.04,
|
||||||
|
"earnings_runup_target_pct": 0.08,
|
||||||
|
"earnings_runup_trailing_activate_pct": 0.05,
|
||||||
|
"earnings_runup_trailing_giveback_pct": 0.03,
|
||||||
|
"earnings_runup_calendar_buffer_days": 1,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": ["earnings_runup", "preevent_drift", "poc"],
|
||||||
|
"version_family": "earnings_runup",
|
||||||
|
"status": "draft",
|
||||||
|
"changelog": "Initial PoC: standalone EarningsRunup engine, 3-7 day pre-print window, attention_z>=1.5, dvol_z>=1.0, -4%/+8%/3%-trail/forced-flat T-1.",
|
||||||
|
"parent": null,
|
||||||
|
"performance_summary": null
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"experiment_name": "peer_sympathy_poc_v1",
|
||||||
|
"dataset_snapshot_id": "midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2",
|
||||||
|
"description": "Standalone PeerSympathy engine isolation backtest. When a sector leader fires a qualifying PEAD trigger (earnings_release / guidance_update / material_contract) with reaction_close >= +5%, buy the top-2 correlated peers (60d return-correlation >= 0.55, computed on [T-65, T-5] window) at next_open. Exits: -3.5% stop, +6% target, max 3 trading days, hard exit if peer's own earnings within 3 trading days. No PEAD engines, no parking, no idle alpha — pure standalone EV measurement.",
|
||||||
|
"base_config": "configs/backtest/return_max_long_v1.json",
|
||||||
|
"overrides": {
|
||||||
|
"signal": {
|
||||||
|
"scoring_model": "return_max_long_v13e",
|
||||||
|
"score_threshold": 0.0,
|
||||||
|
"max_candidates_per_day": 18,
|
||||||
|
"a_tier_score_threshold": 0.99
|
||||||
|
},
|
||||||
|
"risk": {
|
||||||
|
"per_trade_risk_pct": 0.65,
|
||||||
|
"per_trade_risk_pct_a_tier": 0.65,
|
||||||
|
"max_daily_new_risk_pct": 50,
|
||||||
|
"max_positions": 30,
|
||||||
|
"max_positions_per_sector": 30,
|
||||||
|
"max_position_value_pct": 25,
|
||||||
|
"max_adv_fraction": 0.3,
|
||||||
|
"macro_regime_neutral_size_scaler": 1,
|
||||||
|
"macro_regime_risk_off_size_scaler": 1,
|
||||||
|
"veto_unknown_direction": false,
|
||||||
|
"veto_bearish_direction": false,
|
||||||
|
"macro_regime_risk_off_a_tier_only": false,
|
||||||
|
"stop_atr_multiplier": 1.75,
|
||||||
|
"allow_budget_downsizing": true,
|
||||||
|
"cash_parking_preset": null,
|
||||||
|
"fixed_capital_sizing": false
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"trailing_warmup_days": 7,
|
||||||
|
"max_holding_days": 3,
|
||||||
|
"early_failure_no_progress_days": 1,
|
||||||
|
"early_failure_no_progress_r": 0.0,
|
||||||
|
"early_failure_no_progress_fraction": 0,
|
||||||
|
"lookback_entry_enabled": false
|
||||||
|
},
|
||||||
|
"event_type_profiles": {
|
||||||
|
"peer_sympathy": {
|
||||||
|
"enabled": true,
|
||||||
|
"direction_filter": "any",
|
||||||
|
"max_holding_days_override": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idle_alpha_sleeve_preset": null,
|
||||||
|
"form4_capture_sleeve_preset": null,
|
||||||
|
"ownership_capture_sleeve_preset": null,
|
||||||
|
"risk_off_alpha_sleeve_preset": null,
|
||||||
|
"dividend_capture_sleeve_preset": null
|
||||||
|
},
|
||||||
|
"strategy_engines": [
|
||||||
|
{
|
||||||
|
"engine_id": "peer_sympathy_long",
|
||||||
|
"event_types": ["peer_sympathy"],
|
||||||
|
"timing_class": "after_close",
|
||||||
|
"direction": "long_only",
|
||||||
|
"entry_timing_policy": "next_open",
|
||||||
|
"engine_risk_budget_pct": 1.0,
|
||||||
|
"score_threshold_override": 0.0,
|
||||||
|
"max_holding_days": 3,
|
||||||
|
"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,
|
||||||
|
"leader_follower_min_days_to_event": 3,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": ["peer_sympathy", "sympathy_rally", "poc"],
|
||||||
|
"version_family": "peer_sympathy",
|
||||||
|
"status": "draft",
|
||||||
|
"changelog": "Initial PoC: standalone PeerSympathy engine, leader_reaction>=+5%, peer corr>=0.55 over [T-65, T-5], top-2 peers, -3.5%/+6%/3-day-hold, 3-day peer-earnings blackout.",
|
||||||
|
"parent": null,
|
||||||
|
"performance_summary": null
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"experiment_name": "vol_breakout_52w_poc_v1",
|
||||||
|
"dataset_snapshot_id": "broad-liquid-long-v1_bucketfix_full_audit_canonical",
|
||||||
|
"description": "Standalone VolBreakout52w engine isolation backtest. Honest, look-ahead-safe descendant of the retired topgainer family. Buy at next_open T when T-1 close is a 52-week high (close_T-1 > max(high[T-252..T-2])), volume_T-1 >= 2 * median_volume_20d_T-2, and ATR_14_T-1/close_T-1 in [0.015, 0.06]. Skip if pre-open implied gap > +4% (CURRENTLY DISABLED — premarket data not in snapshot; flag vol_breakout_52w_skip_if_no_gap_data=true to suppress the gap guard with a loud warning). Exits: -3% intraday stop, +5% target, max_holding_days=2 (mandatory MOC). Universe: broad (small/mid where 52w-high alpha lives), gated by ADV >= $10M and price >= $5. No PEAD engines, no parking, no idle alpha — pure standalone EV measurement. Falsification plan: (a) run with feature shift 0 vs -1 — gap < 10% of edge; (b) bootstrap permutation of entry-day flags — edge must vanish.",
|
||||||
|
"base_config": "configs/backtest/return_max_long_v1.json",
|
||||||
|
"overrides": {
|
||||||
|
"signal": {
|
||||||
|
"scoring_model": "return_max_long_v13e",
|
||||||
|
"score_threshold": 0.0,
|
||||||
|
"max_candidates_per_day": 30,
|
||||||
|
"a_tier_score_threshold": 0.99
|
||||||
|
},
|
||||||
|
"risk": {
|
||||||
|
"per_trade_risk_pct": 0.5,
|
||||||
|
"per_trade_risk_pct_a_tier": 0.5,
|
||||||
|
"max_daily_new_risk_pct": 50,
|
||||||
|
"max_positions": 30,
|
||||||
|
"max_positions_per_sector": 30,
|
||||||
|
"max_position_value_pct": 25,
|
||||||
|
"max_adv_fraction": 0.3,
|
||||||
|
"macro_regime_neutral_size_scaler": 1,
|
||||||
|
"macro_regime_risk_off_size_scaler": 1,
|
||||||
|
"veto_unknown_direction": false,
|
||||||
|
"veto_bearish_direction": false,
|
||||||
|
"macro_regime_risk_off_a_tier_only": false,
|
||||||
|
"stop_atr_multiplier": 1.5,
|
||||||
|
"allow_budget_downsizing": true,
|
||||||
|
"cash_parking_preset": null,
|
||||||
|
"fixed_capital_sizing": false
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"trailing_warmup_days": 7,
|
||||||
|
"max_holding_days": 2,
|
||||||
|
"early_failure_no_progress_days": 1,
|
||||||
|
"early_failure_no_progress_r": 0.0,
|
||||||
|
"early_failure_no_progress_fraction": 0,
|
||||||
|
"lookback_entry_enabled": false
|
||||||
|
},
|
||||||
|
"event_type_profiles": {
|
||||||
|
"vol_breakout_52w": {
|
||||||
|
"enabled": true,
|
||||||
|
"direction_filter": "any",
|
||||||
|
"max_holding_days_override": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idle_alpha_sleeve_preset": null,
|
||||||
|
"form4_capture_sleeve_preset": null,
|
||||||
|
"ownership_capture_sleeve_preset": null,
|
||||||
|
"risk_off_alpha_sleeve_preset": null,
|
||||||
|
"dividend_capture_sleeve_preset": null
|
||||||
|
},
|
||||||
|
"strategy_engines": [
|
||||||
|
{
|
||||||
|
"engine_id": "vol_breakout_52w_long",
|
||||||
|
"event_types": ["vol_breakout_52w"],
|
||||||
|
"timing_class": "after_close",
|
||||||
|
"direction": "long_only",
|
||||||
|
"entry_timing_policy": "next_open",
|
||||||
|
"engine_risk_budget_pct": 1.0,
|
||||||
|
"score_threshold_override": 0.0,
|
||||||
|
"max_holding_days": 2,
|
||||||
|
"vol_breakout_52w_enabled": true,
|
||||||
|
"vol_breakout_52w_lookback_days": 252,
|
||||||
|
"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": 10000000.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,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": ["vol_breakout_52w", "topgainer_descendant", "lookahead_safe", "poc"],
|
||||||
|
"version_family": "vol_breakout_52w",
|
||||||
|
"status": "draft",
|
||||||
|
"changelog": "Initial PoC: standalone VolBreakout52w engine on broad-liquid snapshot. 52w high + 2x volume + ATR/close band, -3%/+5%/2-day MOC. Pre-open gap guard inactive (premarket data not in snapshot) — vol_breakout_52w_skip_if_no_gap_data=true. Falsification plan: feature-shift honest replay + bootstrap permutation.",
|
||||||
|
"parent": null,
|
||||||
|
"performance_summary": null,
|
||||||
|
"missing_infrastructure": ["pre_open_gap_provider — premarket gap data not in snapshot; the +4% gap-fade guard is INACTIVE for this PoC. Mark all derived PnL accordingly."]
|
||||||
|
}
|
||||||
@ -0,0 +1,512 @@
|
|||||||
|
"""EarningsRunup pre-event drift engine.
|
||||||
|
|
||||||
|
Buys stocks 3-7 trading days before scheduled earnings when both attention and
|
||||||
|
dollar-volume z-scores rise above their 20-day baselines. Exits before the print.
|
||||||
|
|
||||||
|
This module is the *pure* logic — `BacktestRunner` calls into
|
||||||
|
``build_earnings_runup_candidates`` from a thin scheduling hook. The pure
|
||||||
|
function takes provider Protocols so it can be unit-tested with stubs.
|
||||||
|
|
||||||
|
Architectural choice (a): synthetic Candidate emission into the existing
|
||||||
|
`_scheduled_delayed_entries` queue, mirroring `_schedule_leader_follower_candidates`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Iterable, Protocol
|
||||||
|
|
||||||
|
from libs.backtest.domain import (
|
||||||
|
Candidate,
|
||||||
|
LookaheadViolationError,
|
||||||
|
StrategyEngineConfig,
|
||||||
|
)
|
||||||
|
from libs.common.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
EARNINGS_RUNUP_EVENT_TYPE = "earnings_runup_preevent"
|
||||||
|
|
||||||
|
# Eastern-time market open used as the leakage cutoff. Decision date features
|
||||||
|
# must be timestamped strictly before this instant.
|
||||||
|
_ET_MARKET_OPEN = dt.time(9, 30)
|
||||||
|
# Naive UTC offset is fine for ordering checks because every timestamp we
|
||||||
|
# produce is normalized to the same convention (timezone-aware UTC).
|
||||||
|
_ET_OFFSET = dt.timedelta(hours=-5) # EST; DST is irrelevant for an ordering bound
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider Protocols (test-friendly seams)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AttentionZscoreProvider(Protocol):
|
||||||
|
"""Returns the 20-day attention z-score for ``symbol`` as of ``as_of_date``.
|
||||||
|
|
||||||
|
Implementations must guarantee that no information from on/after ``as_of_date``
|
||||||
|
is incorporated into the returned value. ``None`` if data is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class UpcomingEarningsProvider(Protocol):
|
||||||
|
"""Returns the next-known scheduled earnings reaction date for ``symbol`` as of ``as_of_date``."""
|
||||||
|
|
||||||
|
def get_next_reaction_date(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: dt.date,
|
||||||
|
max_lookahead_calendar_days: int,
|
||||||
|
) -> dt.date | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class BarHistoryProvider(Protocol):
|
||||||
|
"""Returns chronologically-ordered (date, bar_dict) pairs for ``symbol`` strictly before ``as_of_date``."""
|
||||||
|
|
||||||
|
def get_bars_before(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: dt.date,
|
||||||
|
lookback_days: int,
|
||||||
|
) -> list[tuple[dt.date, dict[str, Any]]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adapters: bridge BacktestRunner state to the Protocols above.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _PitCalendarUpcomingEarningsAdapter:
|
||||||
|
"""Adapt PointInTimeEarningsCalendar to UpcomingEarningsProvider."""
|
||||||
|
|
||||||
|
pit_calendar: Any # libs.backtest.earnings_calendar.PointInTimeEarningsCalendar
|
||||||
|
trading_days: list[dt.date]
|
||||||
|
|
||||||
|
def get_next_reaction_date(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: dt.date,
|
||||||
|
max_lookahead_calendar_days: int,
|
||||||
|
) -> dt.date | None:
|
||||||
|
try:
|
||||||
|
idx = self.trading_days.index(as_of_date)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
# Look at every trading day strictly after as_of_date up to lookahead window.
|
||||||
|
cutoff = as_of_date + dt.timedelta(days=max_lookahead_calendar_days)
|
||||||
|
future = [d for d in self.trading_days[idx + 1:] if d <= cutoff]
|
||||||
|
if not future:
|
||||||
|
return None
|
||||||
|
result = self.pit_calendar.get_known_upcoming_reaction_dates(
|
||||||
|
as_of_date=as_of_date,
|
||||||
|
allowed_reaction_dates=future,
|
||||||
|
symbols=[symbol],
|
||||||
|
)
|
||||||
|
return result.get(symbol.upper())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SnapshotStoreBarAdapter:
|
||||||
|
"""Adapt SnapshotStore (or any object exposing ._bars) to BarHistoryProvider."""
|
||||||
|
|
||||||
|
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]
|
||||||
|
|
||||||
|
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_by_symbol.get(symbol.upper())
|
||||||
|
if not sym_bars:
|
||||||
|
return []
|
||||||
|
eligible = sorted(
|
||||||
|
(d, sym_bars[d])
|
||||||
|
for d in sym_bars
|
||||||
|
if d < as_of_date # strict; T-1 is the latest allowed
|
||||||
|
)
|
||||||
|
return eligible[-lookback_days:]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lookahead defense
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
|
||||||
|
"""09:30 ET on decision_date, expressed as a UTC-aware timestamp.
|
||||||
|
|
||||||
|
Any feature timestamp >= this instant carries information from inside the
|
||||||
|
entry day and constitutes a look-ahead violation.
|
||||||
|
"""
|
||||||
|
et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN)
|
||||||
|
# Convert to UTC by subtracting the (negative) ET offset.
|
||||||
|
utc_naive = et_naive - _ET_OFFSET
|
||||||
|
return utc_naive.replace(tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_lookahead(
|
||||||
|
symbol: str,
|
||||||
|
decision_date: dt.date,
|
||||||
|
feature_timestamps: Iterable[dt.datetime],
|
||||||
|
) -> None:
|
||||||
|
cutoff = _decision_cutoff_utc(decision_date)
|
||||||
|
for ts in feature_timestamps:
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"EarningsRunup feature timestamp for {symbol} is naive ({ts.isoformat()}); "
|
||||||
|
"all timestamps must be timezone-aware to compare against the cutoff"
|
||||||
|
)
|
||||||
|
if ts >= cutoff:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"EarningsRunup feature timestamp {ts.isoformat()} for {symbol} is "
|
||||||
|
f">= decision_date cutoff {cutoff.isoformat()}; this is a look-ahead violation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trigger evaluation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EarningsRunupTriggerInputs:
|
||||||
|
"""Bundle of T-1 close inputs for one (symbol, decision_date) candidate.
|
||||||
|
|
||||||
|
Every field whose source bears a timestamp must be timestamped strictly
|
||||||
|
before ``decision_date`` 09:30 ET; the candidate builder enforces this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
decision_date: dt.date
|
||||||
|
next_trading_date: dt.date
|
||||||
|
upcoming_earnings_reaction_date: dt.date
|
||||||
|
days_to_earnings: int # trading-day count from decision_date to event
|
||||||
|
attention_zscore_20d: float
|
||||||
|
dollar_volume_zscore_20d: float
|
||||||
|
last_close_price: float
|
||||||
|
avg_dollar_volume_20d: float
|
||||||
|
last_bar_date: dt.date
|
||||||
|
last_bar_timestamp: dt.datetime # tz-aware
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_trigger(
|
||||||
|
inputs: EarningsRunupTriggerInputs,
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Pure trigger check. Returns (passes, reject_reason)."""
|
||||||
|
dmin = engine.earnings_runup_days_to_earnings_min
|
||||||
|
dmax = engine.earnings_runup_days_to_earnings_max
|
||||||
|
if dmin is not None and inputs.days_to_earnings < dmin:
|
||||||
|
return False, f"days_to_earnings {inputs.days_to_earnings} < min {dmin}"
|
||||||
|
if dmax is not None and inputs.days_to_earnings > dmax:
|
||||||
|
return False, f"days_to_earnings {inputs.days_to_earnings} > max {dmax}"
|
||||||
|
|
||||||
|
az_min = engine.earnings_runup_attention_zscore_20d_min
|
||||||
|
if az_min is not None and inputs.attention_zscore_20d < az_min:
|
||||||
|
return False, f"attention_z {inputs.attention_zscore_20d:.3f} < min {az_min}"
|
||||||
|
|
||||||
|
dvz_min = engine.earnings_runup_dollar_volume_zscore_20d_min
|
||||||
|
if dvz_min is not None and inputs.dollar_volume_zscore_20d < dvz_min:
|
||||||
|
return False, f"dollar_volume_z {inputs.dollar_volume_zscore_20d:.3f} < min {dvz_min}"
|
||||||
|
|
||||||
|
adv_min = engine.earnings_runup_min_avg_dollar_volume
|
||||||
|
if adv_min is not None and inputs.avg_dollar_volume_20d < adv_min:
|
||||||
|
return False, (
|
||||||
|
f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} "
|
||||||
|
f"< min {adv_min:,.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dollar-volume z-score from bar history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _dollar_volume_zscore_20d(bars: list[tuple[dt.date, dict[str, Any]]]) -> tuple[float | None, float | None]:
|
||||||
|
"""Compute (zscore_20d, avg_dollar_volume_20d) from the last 21 bars.
|
||||||
|
|
||||||
|
The most recent bar (T-1) is the observation; the prior 20 form the baseline.
|
||||||
|
Returns (None, None) if insufficient history.
|
||||||
|
"""
|
||||||
|
if len(bars) < 21:
|
||||||
|
return None, None
|
||||||
|
recent = bars[-1][1]
|
||||||
|
prior_20 = bars[-21:-1]
|
||||||
|
recent_dv = float(recent.get("close", 0.0)) * float(recent.get("volume", 0.0))
|
||||||
|
prior_dv = [
|
||||||
|
float(b.get("close", 0.0)) * float(b.get("volume", 0.0))
|
||||||
|
for _, b in prior_20
|
||||||
|
]
|
||||||
|
if len(prior_dv) < 2:
|
||||||
|
return None, None
|
||||||
|
mu = statistics.fmean(prior_dv)
|
||||||
|
sigma = statistics.pstdev(prior_dv)
|
||||||
|
if sigma <= 0 or math.isnan(sigma):
|
||||||
|
return None, mu
|
||||||
|
z = (recent_dv - mu) / sigma
|
||||||
|
return z, mu
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_earnings_runup_candidates(
|
||||||
|
decision_date: dt.date,
|
||||||
|
next_trading_date: dt.date,
|
||||||
|
universe_symbols: Iterable[str],
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
upcoming_earnings_provider: UpcomingEarningsProvider,
|
||||||
|
attention_provider: AttentionZscoreProvider,
|
||||||
|
bar_provider: BarHistoryProvider,
|
||||||
|
) -> list[Candidate]:
|
||||||
|
"""Construct synthetic EarningsRunup candidates for ``next_trading_date`` execution.
|
||||||
|
|
||||||
|
Decision logic runs at T-1 close (=decision_date close); orders fill at
|
||||||
|
T+1 next_open. Every input must satisfy ``timestamp < decision_date 09:30 ET``.
|
||||||
|
"""
|
||||||
|
if not engine.earnings_runup_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
dmax = int(engine.earnings_runup_days_to_earnings_max or 0)
|
||||||
|
if dmax <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# PIT calendar adapter looks ``dmax`` trading days ahead. We pad in calendar days
|
||||||
|
# to be safe (weekends/holidays).
|
||||||
|
calendar_lookahead = dmax * 2 + 7
|
||||||
|
|
||||||
|
candidates: list[Candidate] = []
|
||||||
|
seen_symbols: set[str] = set()
|
||||||
|
|
||||||
|
for raw_symbol in universe_symbols:
|
||||||
|
symbol = str(raw_symbol).strip().upper()
|
||||||
|
if not symbol or symbol in seen_symbols:
|
||||||
|
continue
|
||||||
|
seen_symbols.add(symbol)
|
||||||
|
|
||||||
|
upcoming_reaction = upcoming_earnings_provider.get_next_reaction_date(
|
||||||
|
symbol=symbol,
|
||||||
|
as_of_date=decision_date,
|
||||||
|
max_lookahead_calendar_days=calendar_lookahead,
|
||||||
|
)
|
||||||
|
if upcoming_reaction is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Trading-day distance from decision_date close to event reaction.
|
||||||
|
# We compute via the universe's trading-day index when available; the
|
||||||
|
# PIT adapter passes the full sim_dates list, so we recompute from there.
|
||||||
|
days_to_earnings = _trading_days_between(
|
||||||
|
decision_date, upcoming_reaction, getattr(upcoming_earnings_provider, "trading_days", None)
|
||||||
|
)
|
||||||
|
if days_to_earnings is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=65)
|
||||||
|
if not bars:
|
||||||
|
continue
|
||||||
|
last_bar_date, last_bar = bars[-1]
|
||||||
|
|
||||||
|
# Strict T-1 check: most recent allowed bar is the day BEFORE decision_date.
|
||||||
|
# We allow same-day decision_date+ data only if the bar's date < decision_date.
|
||||||
|
if last_bar_date >= decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"EarningsRunup bar for {symbol} on {last_bar_date.isoformat()} is not "
|
||||||
|
f"strictly before decision_date {decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_bar_ts = _bar_close_timestamp(last_bar_date)
|
||||||
|
# Run the lookahead assertion early — it MUST be on the hot path.
|
||||||
|
_assert_no_lookahead(symbol, decision_date, [last_bar_ts])
|
||||||
|
|
||||||
|
dv_z, adv_20d = _dollar_volume_zscore_20d(bars)
|
||||||
|
if dv_z is None or adv_20d is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
attention_z = attention_provider.get_zscore_20d(symbol, decision_date)
|
||||||
|
if attention_z is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_close = float(last_bar.get("close", 0.0))
|
||||||
|
if last_close <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
inputs = EarningsRunupTriggerInputs(
|
||||||
|
symbol=symbol,
|
||||||
|
decision_date=decision_date,
|
||||||
|
next_trading_date=next_trading_date,
|
||||||
|
upcoming_earnings_reaction_date=upcoming_reaction,
|
||||||
|
days_to_earnings=days_to_earnings,
|
||||||
|
attention_zscore_20d=float(attention_z),
|
||||||
|
dollar_volume_zscore_20d=float(dv_z),
|
||||||
|
last_close_price=last_close,
|
||||||
|
avg_dollar_volume_20d=float(adv_20d),
|
||||||
|
last_bar_date=last_bar_date,
|
||||||
|
last_bar_timestamp=last_bar_ts,
|
||||||
|
)
|
||||||
|
|
||||||
|
passes, reason = evaluate_trigger(inputs, engine)
|
||||||
|
if not passes:
|
||||||
|
logger.debug(
|
||||||
|
"earnings_runup_trigger_skipped",
|
||||||
|
symbol=symbol,
|
||||||
|
decision_date=decision_date.isoformat(),
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = _build_candidate_from_inputs(inputs, engine)
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _trading_days_between(
|
||||||
|
decision_date: dt.date,
|
||||||
|
target_date: dt.date,
|
||||||
|
trading_days: list[dt.date] | None,
|
||||||
|
) -> int | None:
|
||||||
|
if trading_days:
|
||||||
|
try:
|
||||||
|
i0 = trading_days.index(decision_date)
|
||||||
|
i1 = trading_days.index(target_date)
|
||||||
|
return i1 - i0
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
# Fallback: business-day approximation if trading_days not available.
|
||||||
|
# Counts weekdays strictly after decision_date up to target_date.
|
||||||
|
if target_date <= decision_date:
|
||||||
|
return None
|
||||||
|
count = 0
|
||||||
|
cursor = decision_date
|
||||||
|
while cursor < target_date:
|
||||||
|
cursor = cursor + dt.timedelta(days=1)
|
||||||
|
if cursor.weekday() < 5:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
|
||||||
|
"""Timestamp the daily-close bar at 16:00 ET on its trading day, in UTC."""
|
||||||
|
et_naive = dt.datetime.combine(bar_date, dt.time(16, 0))
|
||||||
|
utc_naive = et_naive - _ET_OFFSET
|
||||||
|
return utc_naive.replace(tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidate_from_inputs(
|
||||||
|
inputs: EarningsRunupTriggerInputs,
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
) -> Candidate:
|
||||||
|
# Hard hold: forced flat by close of the trading day BEFORE the print.
|
||||||
|
buffer = max(0, int(engine.earnings_runup_calendar_buffer_days))
|
||||||
|
max_holding_days = max(1, inputs.days_to_earnings - buffer)
|
||||||
|
|
||||||
|
# Translate pct exits → existing ATR-multiplier / R-multiple machinery in
|
||||||
|
# `libs.backtest.allocator.compute_stop_price` and `compute_target_price`.
|
||||||
|
# Synthetic ATR := 2% of last close (the same fallback compute_stop_price
|
||||||
|
# uses when atr_14 is missing, but we materialize it so target/stop
|
||||||
|
# downstream consumers see a non-null ATR).
|
||||||
|
# stop_atr_multiplier := stop_pct / 0.02 → produces a stop_distance of
|
||||||
|
# ``stop_pct * close`` for the default dynamic_scaler == 1.0.
|
||||||
|
# target_1_r := target_pct / stop_pct → fixed-R target sits at +target_pct.
|
||||||
|
# target_1_fraction := 1.0 → fully exit at first target.
|
||||||
|
# Trailing pct exits are NOT mapped (no clean equivalent in the standard
|
||||||
|
# trailing system); rely on the engine's trailing_warmup_days override and
|
||||||
|
# capture trailing config in features for diagnostics.
|
||||||
|
synthetic_atr = max(inputs.last_close_price * 0.02, 0.01)
|
||||||
|
stop_pct = float(engine.earnings_runup_stop_pct)
|
||||||
|
target_pct = float(engine.earnings_runup_target_pct)
|
||||||
|
stop_mult = stop_pct / 0.02 if stop_pct > 0 else 2.0
|
||||||
|
target_r = target_pct / stop_pct if stop_pct > 0 else 2.0
|
||||||
|
|
||||||
|
# Score is a deterministic function of the two z-scores so it ranks
|
||||||
|
# candidates without leaking future information.
|
||||||
|
z_sum = inputs.attention_zscore_20d + inputs.dollar_volume_zscore_20d
|
||||||
|
score = 0.5 + 0.05 * z_sum
|
||||||
|
score = max(0.0, min(0.99, score))
|
||||||
|
score_bucket = (
|
||||||
|
"high" if score >= 0.8
|
||||||
|
else "medium_high" if score >= 0.6
|
||||||
|
else "medium"
|
||||||
|
)
|
||||||
|
|
||||||
|
event_id = (
|
||||||
|
f"synth_earnings_runup_{inputs.symbol.lower()}_"
|
||||||
|
f"{inputs.decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
features = {
|
||||||
|
"earnings_runup_decision_date": inputs.decision_date.isoformat(),
|
||||||
|
"earnings_runup_upcoming_reaction_date": inputs.upcoming_earnings_reaction_date.isoformat(),
|
||||||
|
"earnings_runup_days_to_earnings": inputs.days_to_earnings,
|
||||||
|
"earnings_runup_attention_zscore_20d": round(inputs.attention_zscore_20d, 4),
|
||||||
|
"earnings_runup_dollar_volume_zscore_20d": round(inputs.dollar_volume_zscore_20d, 4),
|
||||||
|
"earnings_runup_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
|
||||||
|
"earnings_runup_max_holding_days": max_holding_days,
|
||||||
|
"earnings_runup_stop_pct": engine.earnings_runup_stop_pct,
|
||||||
|
"earnings_runup_target_pct": engine.earnings_runup_target_pct,
|
||||||
|
"earnings_runup_trailing_activate_pct": engine.earnings_runup_trailing_activate_pct,
|
||||||
|
"earnings_runup_trailing_giveback_pct": engine.earnings_runup_trailing_giveback_pct,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Candidate(
|
||||||
|
event_id=event_id,
|
||||||
|
symbol=inputs.symbol,
|
||||||
|
source_symbol=inputs.symbol,
|
||||||
|
score=score,
|
||||||
|
sector="UNKNOWN",
|
||||||
|
event_type=EARNINGS_RUNUP_EVENT_TYPE,
|
||||||
|
event_timestamp=inputs.last_bar_timestamp,
|
||||||
|
event_date=inputs.decision_date,
|
||||||
|
filing_time_bucket="post_market",
|
||||||
|
timing_class="after_close",
|
||||||
|
reaction_date=inputs.decision_date,
|
||||||
|
execution_date=inputs.next_trading_date,
|
||||||
|
entry_price_est=inputs.last_close_price,
|
||||||
|
avg_dollar_volume=inputs.avg_dollar_volume_20d,
|
||||||
|
atr_14=synthetic_atr,
|
||||||
|
score_bucket=score_bucket,
|
||||||
|
engine_id=engine.engine_id,
|
||||||
|
entry_timing_policy="next_open",
|
||||||
|
trade_direction="long",
|
||||||
|
engine_max_holding_days=max_holding_days,
|
||||||
|
engine_risk_budget_pct=engine.engine_risk_budget_pct,
|
||||||
|
engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
|
||||||
|
# Map pct-based EarningsRunup exits → engine_*-prefixed overrides on the candidate.
|
||||||
|
engine_target_1_r=target_r,
|
||||||
|
engine_target_1_fraction=1.0,
|
||||||
|
engine_trailing_model=engine.trailing_model_override,
|
||||||
|
engine_trailing_warmup_days=engine.trailing_warmup_days_override,
|
||||||
|
engine_stop_atr_multiplier=stop_mult,
|
||||||
|
engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
|
||||||
|
engine_use_reaction_day_low_stop=False,
|
||||||
|
engine_early_failure_close_below_entry_and_reaction_close=False,
|
||||||
|
engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override,
|
||||||
|
engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override,
|
||||||
|
engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override,
|
||||||
|
shadow_only=engine.shadow_only,
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EARNINGS_RUNUP_EVENT_TYPE",
|
||||||
|
"AttentionZscoreProvider",
|
||||||
|
"BarHistoryProvider",
|
||||||
|
"EarningsRunupTriggerInputs",
|
||||||
|
"UpcomingEarningsProvider",
|
||||||
|
"_PitCalendarUpcomingEarningsAdapter",
|
||||||
|
"_SnapshotStoreBarAdapter",
|
||||||
|
"build_earnings_runup_candidates",
|
||||||
|
"evaluate_trigger",
|
||||||
|
]
|
||||||
@ -0,0 +1,714 @@
|
|||||||
|
"""PeerSympathy engine.
|
||||||
|
|
||||||
|
When a sector leader fires a qualifying PEAD trigger (earnings/guidance/material
|
||||||
|
contract) with a strong same-day reaction, buy the top-correlated peers at the
|
||||||
|
next open. Catches sympathy rallies (e.g., AVGO/AMD/MU on NVDA's print) that the
|
||||||
|
core PEAD universe-filtered engines architecturally miss because they only fire
|
||||||
|
on the symbol that filed.
|
||||||
|
|
||||||
|
This module is the *pure* logic — `BacktestRunner` calls into
|
||||||
|
``build_peer_sympathy_candidates`` from a thin scheduling hook. Provider
|
||||||
|
Protocols allow stubbed unit tests.
|
||||||
|
|
||||||
|
Architectural choice: synthetic Candidate emission into the existing
|
||||||
|
`_scheduled_delayed_entries` queue, mirroring `_schedule_leader_follower_candidates`
|
||||||
|
and `_schedule_earnings_runup_candidates`.
|
||||||
|
|
||||||
|
Look-ahead defenses (NON-NEGOTIABLE):
|
||||||
|
1. Correlation window is `[T-window_start, T-window_end_skip)`. The last
|
||||||
|
``window_end_skip`` trading days are skipped so peer co-movement during the
|
||||||
|
leader's own pre-event drift cannot leak into the correlation.
|
||||||
|
2. Peer T+0 (leader event day) reaction is NEVER consulted in selection. Only
|
||||||
|
leader's print and the peer's bar history strictly before T are used.
|
||||||
|
3. ``next_trading_date > leader.event_date`` (peer entry strictly after leader
|
||||||
|
publication). Enforced via ``LookaheadViolationError``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Iterable, Protocol
|
||||||
|
|
||||||
|
from libs.backtest.domain import (
|
||||||
|
Candidate,
|
||||||
|
LookaheadViolationError,
|
||||||
|
StrategyEngineConfig,
|
||||||
|
)
|
||||||
|
from libs.common.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
PEER_SYMPATHY_EVENT_TYPE = "peer_sympathy"
|
||||||
|
|
||||||
|
# Eastern-time market open used as the leakage cutoff for peer features.
|
||||||
|
_ET_MARKET_OPEN = dt.time(9, 30)
|
||||||
|
_ET_OFFSET = dt.timedelta(hours=-5) # EST; DST is irrelevant for an ordering bound
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider Protocols
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BarHistoryProvider(Protocol):
|
||||||
|
"""Returns chronologically-ordered (date, bar_dict) pairs for ``symbol`` strictly before ``as_of_date``."""
|
||||||
|
|
||||||
|
def get_bars_before(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: dt.date,
|
||||||
|
lookback_days: int,
|
||||||
|
) -> list[tuple[dt.date, dict[str, Any]]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class UpcomingEarningsProvider(Protocol):
|
||||||
|
"""Returns the next-known scheduled earnings reaction date for ``symbol`` as of ``as_of_date``.
|
||||||
|
|
||||||
|
Used here to enforce the peer-earnings blackout: don't buy a peer whose own
|
||||||
|
print is within ``blackout_days_to_peer_event`` trading days.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_next_reaction_date(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: dt.date,
|
||||||
|
max_lookahead_calendar_days: int,
|
||||||
|
) -> dt.date | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lightweight info struct: leader print as evaluated against engine filters.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LeaderPrint:
|
||||||
|
"""Subset of leader candidate / event row consumed by PeerSympathy.
|
||||||
|
|
||||||
|
The runner adapts ``Candidate`` rows to this struct so the pure logic does
|
||||||
|
not depend on the heavyweight ``Candidate`` model and is trivially fakeable
|
||||||
|
in unit tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
sector: str
|
||||||
|
event_id: str
|
||||||
|
event_type: str
|
||||||
|
event_date: dt.date
|
||||||
|
event_timestamp: dt.datetime # tz-aware
|
||||||
|
reaction_day_return: float
|
||||||
|
score: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trigger
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PeerSympathyTriggerInputs:
|
||||||
|
"""Bundle of inputs for one (peer, decision_date=leader.event_date) trigger evaluation."""
|
||||||
|
|
||||||
|
leader_symbol: str
|
||||||
|
leader_sector: str
|
||||||
|
leader_event_type: str
|
||||||
|
leader_reaction: float
|
||||||
|
peer_symbol: str
|
||||||
|
decision_date: dt.date
|
||||||
|
next_trading_date: dt.date
|
||||||
|
correlation: float
|
||||||
|
peer_last_close: float
|
||||||
|
peer_avg_dollar_volume_20d: float
|
||||||
|
peer_last_bar_date: dt.date
|
||||||
|
peer_last_bar_timestamp: dt.datetime # tz-aware
|
||||||
|
peer_upcoming_earnings_reaction_date: dt.date | None
|
||||||
|
peer_trading_days_to_own_earnings: int | None
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_trigger(
|
||||||
|
inputs: PeerSympathyTriggerInputs,
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Pure trigger check. Returns (passes, reject_reason)."""
|
||||||
|
allowed_event_types = {
|
||||||
|
str(e).strip().lower()
|
||||||
|
for e in (engine.peer_sympathy_leader_event_types or [])
|
||||||
|
if str(e).strip()
|
||||||
|
}
|
||||||
|
if allowed_event_types and inputs.leader_event_type.lower() not in allowed_event_types:
|
||||||
|
return False, f"leader_event_type {inputs.leader_event_type!r} not in {sorted(allowed_event_types)}"
|
||||||
|
|
||||||
|
if inputs.leader_reaction < engine.peer_sympathy_leader_reaction_min:
|
||||||
|
return False, (
|
||||||
|
f"leader_reaction {inputs.leader_reaction:.4f} < "
|
||||||
|
f"min {engine.peer_sympathy_leader_reaction_min}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if inputs.correlation < engine.peer_sympathy_correlation_min:
|
||||||
|
return False, (
|
||||||
|
f"correlation {inputs.correlation:.4f} < min {engine.peer_sympathy_correlation_min}"
|
||||||
|
)
|
||||||
|
|
||||||
|
blackout = max(0, int(engine.peer_sympathy_blackout_days_to_peer_event or 0))
|
||||||
|
if blackout > 0 and inputs.peer_trading_days_to_own_earnings is not None:
|
||||||
|
if inputs.peer_trading_days_to_own_earnings <= blackout:
|
||||||
|
return False, (
|
||||||
|
f"peer_trading_days_to_own_earnings "
|
||||||
|
f"{inputs.peer_trading_days_to_own_earnings} <= blackout {blackout}"
|
||||||
|
)
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Look-ahead helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
|
||||||
|
"""09:30 ET on decision_date, expressed as a UTC-aware timestamp.
|
||||||
|
|
||||||
|
Any feature timestamp >= this instant carries information from inside the
|
||||||
|
entry day and constitutes a look-ahead violation. The decision day for
|
||||||
|
peer entry is ``next_trading_date``, NOT ``decision_date`` (=leader.event_date),
|
||||||
|
so the cutoff for peer features is ``next_trading_date``'s 09:30 ET.
|
||||||
|
"""
|
||||||
|
et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN)
|
||||||
|
utc_naive = et_naive - _ET_OFFSET
|
||||||
|
return utc_naive.replace(tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_lookahead(
|
||||||
|
symbol: str,
|
||||||
|
decision_date: dt.date,
|
||||||
|
feature_timestamps: Iterable[dt.datetime],
|
||||||
|
) -> None:
|
||||||
|
cutoff = _decision_cutoff_utc(decision_date)
|
||||||
|
for ts in feature_timestamps:
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"PeerSympathy feature timestamp for {symbol} is naive ({ts.isoformat()}); "
|
||||||
|
"all timestamps must be timezone-aware"
|
||||||
|
)
|
||||||
|
if ts >= cutoff:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"PeerSympathy feature timestamp {ts.isoformat()} for {symbol} is "
|
||||||
|
f">= decision cutoff {cutoff.isoformat()}; this is a look-ahead violation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_correlation_window_safe(
|
||||||
|
*,
|
||||||
|
leader_symbol: str,
|
||||||
|
peer_symbol: str,
|
||||||
|
decision_date: dt.date,
|
||||||
|
window_end_skip: int,
|
||||||
|
used_dates: list[dt.date],
|
||||||
|
trading_days: list[dt.date] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Assert that NO date used in the correlation series is within
|
||||||
|
``window_end_skip`` trading days of ``decision_date``.
|
||||||
|
|
||||||
|
This is the canonical 'last N days skipped' invariant. We compute the
|
||||||
|
forbidden boundary as the trading day exactly ``window_end_skip`` days
|
||||||
|
BEFORE ``decision_date`` (or, if the trading-day list is missing, fall back
|
||||||
|
to a calendar-day approximation that is strictly conservative).
|
||||||
|
"""
|
||||||
|
if not used_dates:
|
||||||
|
return
|
||||||
|
|
||||||
|
if trading_days:
|
||||||
|
try:
|
||||||
|
d_idx = trading_days.index(decision_date)
|
||||||
|
except ValueError:
|
||||||
|
# decision_date not in the calendar — fall back to calendar-day check.
|
||||||
|
forbidden_floor = decision_date - dt.timedelta(days=window_end_skip)
|
||||||
|
else:
|
||||||
|
cut = max(0, d_idx - window_end_skip)
|
||||||
|
forbidden_floor = trading_days[cut] if cut < len(trading_days) else trading_days[0]
|
||||||
|
else:
|
||||||
|
# Calendar-day fallback: ``window_end_skip`` calendar days. Conservative.
|
||||||
|
forbidden_floor = decision_date - dt.timedelta(days=window_end_skip)
|
||||||
|
|
||||||
|
most_recent_used = max(used_dates)
|
||||||
|
if most_recent_used >= forbidden_floor:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"PeerSympathy correlation window for ({leader_symbol},{peer_symbol}) "
|
||||||
|
f"included {most_recent_used.isoformat()} which is within {window_end_skip} "
|
||||||
|
f"trading days of decision_date {decision_date.isoformat()} "
|
||||||
|
f"(forbidden floor {forbidden_floor.isoformat()}); "
|
||||||
|
"the last N days MUST be skipped to avoid co-movement leakage"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Correlation: shared-date log-return Pearson on bars strictly before T-skip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _log_returns_by_date(
|
||||||
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
||||||
|
) -> list[tuple[dt.date, float]]:
|
||||||
|
"""Pairwise log-returns ln(close_t / close_{t-1}); date is the close date of t."""
|
||||||
|
out: list[tuple[dt.date, float]] = []
|
||||||
|
prev_close: float | None = None
|
||||||
|
for d, bar in bars:
|
||||||
|
close = float(bar.get("close", 0.0))
|
||||||
|
if close <= 0:
|
||||||
|
prev_close = None
|
||||||
|
continue
|
||||||
|
if prev_close is not None and prev_close > 0:
|
||||||
|
out.append((d, math.log(close / prev_close)))
|
||||||
|
prev_close = close
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_correlation(
|
||||||
|
leader_bars: list[tuple[dt.date, dict[str, Any]]],
|
||||||
|
peer_bars: list[tuple[dt.date, dict[str, Any]]],
|
||||||
|
*,
|
||||||
|
decision_date: dt.date,
|
||||||
|
window_start: int,
|
||||||
|
window_end_skip: int,
|
||||||
|
trading_days: list[dt.date] | None = None,
|
||||||
|
) -> tuple[float | None, list[dt.date]]:
|
||||||
|
"""Pearson correlation of log-returns over the [T-window_start, T-window_end_skip) window.
|
||||||
|
|
||||||
|
Returns ``(correlation, used_dates)``. ``correlation`` is ``None`` if there
|
||||||
|
are insufficient overlapping observations (< 5 paired returns).
|
||||||
|
|
||||||
|
The function intentionally never reads bars dated >= decision_date — that
|
||||||
|
would be a look-ahead — and always strips the last ``window_end_skip``
|
||||||
|
trading days from the eligible-date set.
|
||||||
|
"""
|
||||||
|
if window_start <= 0 or window_end_skip < 0 or window_start <= window_end_skip:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
# Determine the latest allowable date in the window (strictly before T-skip).
|
||||||
|
if trading_days:
|
||||||
|
try:
|
||||||
|
d_idx = trading_days.index(decision_date)
|
||||||
|
except ValueError:
|
||||||
|
d_idx = None
|
||||||
|
if d_idx is not None:
|
||||||
|
top_idx = d_idx - window_end_skip # exclusive upper bound on dates
|
||||||
|
bot_idx = max(0, d_idx - window_start)
|
||||||
|
if top_idx <= bot_idx:
|
||||||
|
return None, []
|
||||||
|
allowed_dates = set(trading_days[bot_idx:top_idx])
|
||||||
|
else:
|
||||||
|
allowed_dates = None
|
||||||
|
else:
|
||||||
|
allowed_dates = None
|
||||||
|
|
||||||
|
leader_returns = _log_returns_by_date(leader_bars)
|
||||||
|
peer_returns = _log_returns_by_date(peer_bars)
|
||||||
|
|
||||||
|
leader_by_date = dict(leader_returns)
|
||||||
|
peer_by_date = dict(peer_returns)
|
||||||
|
shared = sorted(set(leader_by_date) & set(peer_by_date))
|
||||||
|
|
||||||
|
# Apply allowed-date filter when we know the trading calendar.
|
||||||
|
if allowed_dates is not None:
|
||||||
|
shared = [d for d in shared if d in allowed_dates]
|
||||||
|
else:
|
||||||
|
# Calendar-day fallback: drop dates within ``window_end_skip`` calendar days
|
||||||
|
# of decision_date AND keep only dates within ``window_start`` calendar days.
|
||||||
|
skip_floor = decision_date - dt.timedelta(days=window_end_skip)
|
||||||
|
start_floor = decision_date - dt.timedelta(days=window_start * 2) # generous
|
||||||
|
shared = [d for d in shared if d < skip_floor and d >= start_floor]
|
||||||
|
|
||||||
|
# Strict ceiling: all dates must be < decision_date (defence in depth).
|
||||||
|
shared = [d for d in shared if d < decision_date]
|
||||||
|
|
||||||
|
if len(shared) < 5:
|
||||||
|
return None, shared
|
||||||
|
|
||||||
|
leader_xs = [leader_by_date[d] for d in shared]
|
||||||
|
peer_xs = [peer_by_date[d] for d in shared]
|
||||||
|
n = len(leader_xs)
|
||||||
|
mean_l = statistics.fmean(leader_xs)
|
||||||
|
mean_p = statistics.fmean(peer_xs)
|
||||||
|
cov = sum((leader_xs[i] - mean_l) * (peer_xs[i] - mean_p) for i in range(n)) / n
|
||||||
|
var_l = sum((x - mean_l) ** 2 for x in leader_xs) / n
|
||||||
|
var_p = sum((x - mean_p) ** 2 for x in peer_xs) / n
|
||||||
|
if var_l <= 0 or var_p <= 0:
|
||||||
|
return None, shared
|
||||||
|
rho = cov / math.sqrt(var_l * var_p)
|
||||||
|
if math.isnan(rho) or math.isinf(rho):
|
||||||
|
return None, shared
|
||||||
|
return float(rho), shared
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_peer_sympathy_candidates(
|
||||||
|
decision_date: dt.date,
|
||||||
|
next_trading_date: dt.date,
|
||||||
|
leaders: Iterable[LeaderPrint],
|
||||||
|
peer_resolver: "PeerResolver",
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
bar_provider: BarHistoryProvider,
|
||||||
|
upcoming_earnings_provider: UpcomingEarningsProvider | None = None,
|
||||||
|
trading_days: list[dt.date] | None = None,
|
||||||
|
) -> list[Candidate]:
|
||||||
|
"""Construct synthetic peer-sympathy candidates for ``next_trading_date`` execution.
|
||||||
|
|
||||||
|
``leaders`` are the qualifying leader prints from T (=decision_date). For
|
||||||
|
each leader we:
|
||||||
|
- resolve its peer set,
|
||||||
|
- compute correlation on the [T-window_start, T-window_end_skip) window,
|
||||||
|
- drop peers below ``correlation_min``,
|
||||||
|
- keep top-N peers by correlation,
|
||||||
|
- emit a synthetic Candidate per peer.
|
||||||
|
"""
|
||||||
|
if not engine.peer_sympathy_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Lookahead invariant: peer entry must be strictly after leader publication.
|
||||||
|
if next_trading_date <= decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"PeerSympathy next_trading_date {next_trading_date.isoformat()} must be "
|
||||||
|
f"strictly after leader event_date {decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates: list[Candidate] = []
|
||||||
|
seen_peer_for_decision: set[str] = set()
|
||||||
|
|
||||||
|
allowed_event_types = {
|
||||||
|
str(e).strip().lower()
|
||||||
|
for e in (engine.peer_sympathy_leader_event_types or [])
|
||||||
|
if str(e).strip()
|
||||||
|
}
|
||||||
|
leader_reaction_min = float(engine.peer_sympathy_leader_reaction_min)
|
||||||
|
corr_min = float(engine.peer_sympathy_correlation_min)
|
||||||
|
window_start = int(engine.peer_sympathy_correlation_window_start)
|
||||||
|
window_end_skip = int(engine.peer_sympathy_correlation_window_end_skip)
|
||||||
|
top_n = max(1, int(engine.peer_sympathy_top_n_peers or 1))
|
||||||
|
blackout = max(0, int(engine.peer_sympathy_blackout_days_to_peer_event or 0))
|
||||||
|
|
||||||
|
for leader in leaders:
|
||||||
|
leader_symbol = str(leader.symbol or "").strip().upper()
|
||||||
|
if not leader_symbol:
|
||||||
|
continue
|
||||||
|
# Cheap leader-side gates first to avoid unnecessary bar fetches.
|
||||||
|
if allowed_event_types and leader.event_type.lower() not in allowed_event_types:
|
||||||
|
continue
|
||||||
|
if leader.reaction_day_return < leader_reaction_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Lookahead: leader event_timestamp must precede peer entry cutoff.
|
||||||
|
peer_decision_cutoff = _decision_cutoff_utc(next_trading_date)
|
||||||
|
if leader.event_timestamp.tzinfo is None:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"PeerSympathy leader {leader_symbol} has naive event_timestamp "
|
||||||
|
f"{leader.event_timestamp.isoformat()}"
|
||||||
|
)
|
||||||
|
if leader.event_timestamp >= peer_decision_cutoff:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"PeerSympathy leader {leader_symbol} event_timestamp "
|
||||||
|
f"{leader.event_timestamp.isoformat()} is at-or-after peer entry cutoff "
|
||||||
|
f"{peer_decision_cutoff.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pull leader bars once per leader.
|
||||||
|
leader_bars = bar_provider.get_bars_before(
|
||||||
|
leader_symbol, decision_date, lookback_days=window_start + 5
|
||||||
|
)
|
||||||
|
if len(leader_bars) < window_start - window_end_skip:
|
||||||
|
logger.debug(
|
||||||
|
"peer_sympathy_skip_leader_insufficient_bars",
|
||||||
|
leader=leader_symbol,
|
||||||
|
bars=len(leader_bars),
|
||||||
|
decision_date=decision_date.isoformat(),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
peers = peer_resolver.peers_for_leader(engine, leader_symbol, leader.sector)
|
||||||
|
if not peers:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Compute correlation per peer; collect (peer, corr, last_bar_meta).
|
||||||
|
scored_peers: list[tuple[str, float, list[tuple[dt.date, dict[str, Any]]]]] = []
|
||||||
|
for peer_symbol in peers:
|
||||||
|
peer_symbol = str(peer_symbol).strip().upper()
|
||||||
|
if not peer_symbol or peer_symbol == leader_symbol:
|
||||||
|
continue
|
||||||
|
peer_bars = bar_provider.get_bars_before(
|
||||||
|
peer_symbol, decision_date, lookback_days=window_start + 5
|
||||||
|
)
|
||||||
|
if len(peer_bars) < window_start - window_end_skip:
|
||||||
|
continue
|
||||||
|
corr, used_dates = compute_correlation(
|
||||||
|
leader_bars,
|
||||||
|
peer_bars,
|
||||||
|
decision_date=decision_date,
|
||||||
|
window_start=window_start,
|
||||||
|
window_end_skip=window_end_skip,
|
||||||
|
trading_days=trading_days,
|
||||||
|
)
|
||||||
|
if corr is None:
|
||||||
|
continue
|
||||||
|
# Hot-path lookahead assertion on the dates actually used.
|
||||||
|
_assert_correlation_window_safe(
|
||||||
|
leader_symbol=leader_symbol,
|
||||||
|
peer_symbol=peer_symbol,
|
||||||
|
decision_date=decision_date,
|
||||||
|
window_end_skip=window_end_skip,
|
||||||
|
used_dates=used_dates,
|
||||||
|
trading_days=trading_days,
|
||||||
|
)
|
||||||
|
if corr < corr_min:
|
||||||
|
continue
|
||||||
|
scored_peers.append((peer_symbol, corr, peer_bars))
|
||||||
|
|
||||||
|
# Top-N peers by correlation.
|
||||||
|
scored_peers.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
scored_peers = scored_peers[:top_n]
|
||||||
|
|
||||||
|
for peer_symbol, corr, peer_bars in scored_peers:
|
||||||
|
if peer_symbol in seen_peer_for_decision:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_bar_date, last_bar = peer_bars[-1]
|
||||||
|
if last_bar_date >= decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"PeerSympathy peer bar for {peer_symbol} on {last_bar_date.isoformat()} "
|
||||||
|
f"is not strictly before decision_date {decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_close = float(last_bar.get("close", 0.0))
|
||||||
|
if last_close <= 0:
|
||||||
|
continue
|
||||||
|
volumes = [float(b.get("volume", 0.0)) for _, b in peer_bars[-20:]]
|
||||||
|
closes = [float(b.get("close", 0.0)) for _, b in peer_bars[-20:]]
|
||||||
|
if len(volumes) < 5:
|
||||||
|
continue
|
||||||
|
adv_20d = statistics.fmean(c * v for c, v in zip(closes, volumes))
|
||||||
|
|
||||||
|
peer_last_bar_ts = _bar_close_timestamp(last_bar_date)
|
||||||
|
_assert_no_lookahead(
|
||||||
|
peer_symbol, next_trading_date, [peer_last_bar_ts, leader.event_timestamp]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Peer-earnings blackout — uses an UpcomingEarningsProvider if available.
|
||||||
|
peer_upcoming = None
|
||||||
|
peer_days_to_own = None
|
||||||
|
if upcoming_earnings_provider is not None and blackout > 0:
|
||||||
|
peer_upcoming = upcoming_earnings_provider.get_next_reaction_date(
|
||||||
|
symbol=peer_symbol,
|
||||||
|
as_of_date=decision_date,
|
||||||
|
max_lookahead_calendar_days=blackout * 3 + 7,
|
||||||
|
)
|
||||||
|
if peer_upcoming is not None:
|
||||||
|
peer_days_to_own = _trading_days_between(
|
||||||
|
next_trading_date, peer_upcoming, trading_days
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs = PeerSympathyTriggerInputs(
|
||||||
|
leader_symbol=leader_symbol,
|
||||||
|
leader_sector=leader.sector,
|
||||||
|
leader_event_type=leader.event_type,
|
||||||
|
leader_reaction=leader.reaction_day_return,
|
||||||
|
peer_symbol=peer_symbol,
|
||||||
|
decision_date=decision_date,
|
||||||
|
next_trading_date=next_trading_date,
|
||||||
|
correlation=corr,
|
||||||
|
peer_last_close=last_close,
|
||||||
|
peer_avg_dollar_volume_20d=adv_20d,
|
||||||
|
peer_last_bar_date=last_bar_date,
|
||||||
|
peer_last_bar_timestamp=peer_last_bar_ts,
|
||||||
|
peer_upcoming_earnings_reaction_date=peer_upcoming,
|
||||||
|
peer_trading_days_to_own_earnings=peer_days_to_own,
|
||||||
|
)
|
||||||
|
passes, reason = evaluate_trigger(inputs, engine)
|
||||||
|
if not passes:
|
||||||
|
logger.debug(
|
||||||
|
"peer_sympathy_trigger_skipped",
|
||||||
|
leader=leader_symbol,
|
||||||
|
peer=peer_symbol,
|
||||||
|
decision_date=decision_date.isoformat(),
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = _build_candidate_from_inputs(inputs, leader, engine)
|
||||||
|
candidates.append(candidate)
|
||||||
|
seen_peer_for_decision.add(peer_symbol)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Peer resolver (Protocol so the runner adapter and the test fake share an API)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PeerResolver(Protocol):
|
||||||
|
"""Resolves a leader's peer set, filtered by engine config.
|
||||||
|
|
||||||
|
Reuses the existing leader-follower infra
|
||||||
|
(``leader_follower_extra_peer_symbols_by_sector``,
|
||||||
|
``leader_follower_extra_peer_symbols_by_leader``,
|
||||||
|
``leader_follower_allowed_peer_symbols``) and the proxies module's
|
||||||
|
``peer_candidates_for_symbol`` (sector-ETF holdings + per-leader curated set).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def peers_for_leader(
|
||||||
|
self,
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
leader_symbol: str,
|
||||||
|
leader_sector: str,
|
||||||
|
) -> list[str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _trading_days_between(
|
||||||
|
start_date: dt.date,
|
||||||
|
target_date: dt.date,
|
||||||
|
trading_days: list[dt.date] | None,
|
||||||
|
) -> int | None:
|
||||||
|
if trading_days:
|
||||||
|
try:
|
||||||
|
i0 = trading_days.index(start_date)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
i1 = trading_days.index(target_date)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return i1 - i0
|
||||||
|
if target_date <= start_date:
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
cursor = start_date
|
||||||
|
while cursor < target_date:
|
||||||
|
cursor = cursor + dt.timedelta(days=1)
|
||||||
|
if cursor.weekday() < 5:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
|
||||||
|
et_naive = dt.datetime.combine(bar_date, dt.time(16, 0))
|
||||||
|
utc_naive = et_naive - _ET_OFFSET
|
||||||
|
return utc_naive.replace(tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidate_from_inputs(
|
||||||
|
inputs: PeerSympathyTriggerInputs,
|
||||||
|
leader: LeaderPrint,
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
) -> Candidate:
|
||||||
|
# Map pct exits to the existing ATR-multiplier / R-multiple machinery.
|
||||||
|
synthetic_atr = max(inputs.peer_last_close * 0.02, 0.01)
|
||||||
|
stop_pct = float(engine.peer_sympathy_stop_pct)
|
||||||
|
target_pct = float(engine.peer_sympathy_target_pct)
|
||||||
|
stop_mult = stop_pct / 0.02 if stop_pct > 0 else 1.75
|
||||||
|
target_r = target_pct / stop_pct if stop_pct > 0 else 1.71
|
||||||
|
|
||||||
|
max_holding_days = max(1, int(engine.peer_sympathy_max_holding_days or 3))
|
||||||
|
if (
|
||||||
|
inputs.peer_trading_days_to_own_earnings is not None
|
||||||
|
and engine.peer_sympathy_blackout_days_to_peer_event > 0
|
||||||
|
):
|
||||||
|
# Forced-flat at most 1 day before the peer's own print.
|
||||||
|
ceiling = max(
|
||||||
|
1,
|
||||||
|
int(inputs.peer_trading_days_to_own_earnings)
|
||||||
|
- int(engine.peer_sympathy_blackout_days_to_peer_event),
|
||||||
|
)
|
||||||
|
max_holding_days = min(max_holding_days, ceiling)
|
||||||
|
|
||||||
|
score = min(0.99, max(0.0, 0.5 + 0.5 * (inputs.correlation - engine.peer_sympathy_correlation_min)))
|
||||||
|
score_bucket = (
|
||||||
|
"high" if score >= 0.8
|
||||||
|
else "medium_high" if score >= 0.6
|
||||||
|
else "medium"
|
||||||
|
)
|
||||||
|
|
||||||
|
event_id = (
|
||||||
|
f"synth_peer_sympathy_{inputs.leader_symbol.lower()}_"
|
||||||
|
f"{inputs.peer_symbol.lower()}_{inputs.decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
features = {
|
||||||
|
"peer_sympathy_leader_symbol": inputs.leader_symbol,
|
||||||
|
"peer_sympathy_leader_event_id": leader.event_id,
|
||||||
|
"peer_sympathy_leader_event_type": leader.event_type,
|
||||||
|
"peer_sympathy_leader_reaction_day_return": inputs.leader_reaction,
|
||||||
|
"peer_sympathy_peer_symbol": inputs.peer_symbol,
|
||||||
|
"peer_sympathy_correlation": round(inputs.correlation, 4),
|
||||||
|
"peer_sympathy_correlation_window_start": engine.peer_sympathy_correlation_window_start,
|
||||||
|
"peer_sympathy_correlation_window_end_skip": engine.peer_sympathy_correlation_window_end_skip,
|
||||||
|
"peer_sympathy_stop_pct": engine.peer_sympathy_stop_pct,
|
||||||
|
"peer_sympathy_target_pct": engine.peer_sympathy_target_pct,
|
||||||
|
"peer_sympathy_max_holding_days": max_holding_days,
|
||||||
|
"peer_sympathy_peer_upcoming_earnings": (
|
||||||
|
inputs.peer_upcoming_earnings_reaction_date.isoformat()
|
||||||
|
if inputs.peer_upcoming_earnings_reaction_date is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"peer_sympathy_peer_trading_days_to_own_earnings": inputs.peer_trading_days_to_own_earnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Candidate(
|
||||||
|
event_id=event_id,
|
||||||
|
symbol=inputs.peer_symbol,
|
||||||
|
source_symbol=inputs.leader_symbol,
|
||||||
|
score=score,
|
||||||
|
sector=inputs.leader_sector or "UNKNOWN",
|
||||||
|
event_type=PEER_SYMPATHY_EVENT_TYPE,
|
||||||
|
event_timestamp=inputs.peer_last_bar_timestamp,
|
||||||
|
event_date=inputs.decision_date,
|
||||||
|
filing_time_bucket="post_market",
|
||||||
|
timing_class="after_close",
|
||||||
|
reaction_date=inputs.decision_date,
|
||||||
|
execution_date=inputs.next_trading_date,
|
||||||
|
entry_price_est=inputs.peer_last_close,
|
||||||
|
avg_dollar_volume=inputs.peer_avg_dollar_volume_20d,
|
||||||
|
atr_14=synthetic_atr,
|
||||||
|
score_bucket=score_bucket,
|
||||||
|
engine_id=engine.engine_id,
|
||||||
|
entry_timing_policy="next_open",
|
||||||
|
trade_direction="long",
|
||||||
|
engine_max_holding_days=max_holding_days,
|
||||||
|
engine_risk_budget_pct=engine.engine_risk_budget_pct,
|
||||||
|
engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
|
||||||
|
engine_target_1_r=target_r,
|
||||||
|
engine_target_1_fraction=1.0,
|
||||||
|
engine_trailing_model=engine.trailing_model_override,
|
||||||
|
engine_trailing_warmup_days=engine.trailing_warmup_days_override,
|
||||||
|
engine_stop_atr_multiplier=stop_mult,
|
||||||
|
engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
|
||||||
|
engine_use_reaction_day_low_stop=False,
|
||||||
|
engine_early_failure_close_below_entry_and_reaction_close=False,
|
||||||
|
engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override,
|
||||||
|
engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override,
|
||||||
|
engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override,
|
||||||
|
shadow_only=engine.shadow_only,
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PEER_SYMPATHY_EVENT_TYPE",
|
||||||
|
"BarHistoryProvider",
|
||||||
|
"LeaderPrint",
|
||||||
|
"PeerResolver",
|
||||||
|
"PeerSympathyTriggerInputs",
|
||||||
|
"UpcomingEarningsProvider",
|
||||||
|
"build_peer_sympathy_candidates",
|
||||||
|
"compute_correlation",
|
||||||
|
"evaluate_trigger",
|
||||||
|
]
|
||||||
@ -0,0 +1,734 @@
|
|||||||
|
"""VolBreakout52w — honest, look-ahead-safe descendant of the retired topgainer family.
|
||||||
|
|
||||||
|
Buy at next_open T when T-1 close is a 52-week high with volume confirmation;
|
||||||
|
hold to next-day close. Designed to NEVER repeat the topgainer v1-v54 lookahead bug
|
||||||
|
(see memory: project_topgainer_phase1_lookahead_2026-05-05.md):
|
||||||
|
|
||||||
|
Phase-1 pre-screen used today's daily_high → +267% Sharpe 13.73 collapsed to
|
||||||
|
-4.3% Sharpe -1.04 once removed.
|
||||||
|
|
||||||
|
Trigger conditions (ALL evaluated using only data ending T-1):
|
||||||
|
1. close_T-1 > max(high[T-252..T-2])
|
||||||
|
2. volume_T-1 >= 2 * median_volume_20d_T-2
|
||||||
|
3. ATR_14_T-1 / close_T-1 in [0.015, 0.06]
|
||||||
|
|
||||||
|
Entry: T next_open. Skip if pre-open implied gap > +4% (when gap data available).
|
||||||
|
Exit: -3% intraday stop, +5% target, max_holding_days = 2 (mandatory MOC day 2).
|
||||||
|
|
||||||
|
Architectural choice (a) — synthetic Candidate emission into the existing
|
||||||
|
``_scheduled_delayed_entries`` queue, mirroring EarningsRunup / PeerSympathy.
|
||||||
|
Production path (b) — pre-compute features into the snapshot — left as a follow-up.
|
||||||
|
|
||||||
|
Look-ahead defenses (NON-NEGOTIABLE):
|
||||||
|
* BarHistoryProvider boundary returns bars STRICTLY before decision_date.
|
||||||
|
* ``_assert_features_strictly_before_decision_open`` checks every feature
|
||||||
|
timestamp against 09:30 ET on the decision day.
|
||||||
|
* Defence-in-depth: ``evaluate_trigger`` re-asserts ``last_bar_date < decision_date``
|
||||||
|
so a future maintainer cannot accidentally introduce a T+0 feature path.
|
||||||
|
* ``FrozenT1Features`` typed wrapper raises ``LookaheadViolationError`` on
|
||||||
|
construction if any field's source date >= decision_date.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Iterable, Protocol
|
||||||
|
|
||||||
|
from libs.backtest.domain import (
|
||||||
|
Candidate,
|
||||||
|
LookaheadViolationError,
|
||||||
|
StrategyEngineConfig,
|
||||||
|
)
|
||||||
|
from libs.common.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
VOL_BREAKOUT_52W_EVENT_TYPE = "vol_breakout_52w"
|
||||||
|
|
||||||
|
# Eastern-time market open used as the leakage cutoff.
|
||||||
|
_ET_MARKET_OPEN = dt.time(9, 30)
|
||||||
|
_ET_OFFSET = dt.timedelta(hours=-5) # EST; DST irrelevant for an ordering bound
|
||||||
|
|
||||||
|
# Forbidden field substrings at the screener level — any column whose name
|
||||||
|
# encodes the entry-day's intraday/EOD data is a categorical look-ahead.
|
||||||
|
_FORBIDDEN_T0_FIELD_SUBSTRINGS: tuple[str, ...] = (
|
||||||
|
"daily_high",
|
||||||
|
"daily_low",
|
||||||
|
"daily_close",
|
||||||
|
"intraday_high",
|
||||||
|
"intraday_low",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider Protocols
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BarHistoryProvider(Protocol):
|
||||||
|
"""Returns chronologically-ordered (date, bar_dict) pairs for ``symbol`` strictly before ``as_of_date``."""
|
||||||
|
|
||||||
|
def get_bars_before(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: dt.date,
|
||||||
|
lookback_days: int,
|
||||||
|
) -> list[tuple[dt.date, dict[str, Any]]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PreOpenGapProvider(Protocol):
|
||||||
|
"""Returns the implied pre-open gap for ``symbol`` on ``decision_date``'s next session.
|
||||||
|
|
||||||
|
``None`` if data is unavailable for that symbol/date. Implementations MUST
|
||||||
|
use only premarket data observed before 09:30 ET on the next trading day.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_pre_open_gap_pct(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
next_trading_date: dt.date,
|
||||||
|
prev_close: float,
|
||||||
|
) -> float | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lookahead defense — cutoff and assertions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
|
||||||
|
"""09:30 ET on decision_date, expressed as a UTC-aware timestamp."""
|
||||||
|
et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN)
|
||||||
|
utc_naive = et_naive - _ET_OFFSET
|
||||||
|
return utc_naive.replace(tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_features_strictly_before_decision_open(
|
||||||
|
symbol: str,
|
||||||
|
decision_date: dt.date,
|
||||||
|
feature_timestamps: Iterable[dt.datetime],
|
||||||
|
) -> None:
|
||||||
|
"""Raise LookaheadViolationError if any feature timestamp >= 09:30 ET on decision_date."""
|
||||||
|
cutoff = _decision_cutoff_utc(decision_date)
|
||||||
|
for ts in feature_timestamps:
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"VolBreakout52w feature timestamp for {symbol} is naive ({ts.isoformat()}); "
|
||||||
|
"all timestamps must be timezone-aware to compare against the cutoff"
|
||||||
|
)
|
||||||
|
if ts >= cutoff:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"VolBreakout52w feature timestamp {ts.isoformat()} for {symbol} is "
|
||||||
|
f">= decision_date cutoff {cutoff.isoformat()}; this is a look-ahead violation"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
|
||||||
|
"""Timestamp the daily-close bar at 16:00 ET on its trading day, in UTC."""
|
||||||
|
et_naive = dt.datetime.combine(bar_date, dt.time(16, 0))
|
||||||
|
utc_naive = et_naive - _ET_OFFSET
|
||||||
|
return utc_naive.replace(tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FrozenT1Features — typed wrapper that refuses to hold T+0 data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FrozenT1Features:
|
||||||
|
"""Strict T-1 (or earlier) feature bundle.
|
||||||
|
|
||||||
|
Construction validates that every source date is strictly before
|
||||||
|
``decision_date`` AND that no field name encodes entry-day data
|
||||||
|
(``daily_high``, ``daily_low``, ``daily_close``, ...). Either raises
|
||||||
|
``LookaheadViolationError`` immediately.
|
||||||
|
|
||||||
|
This is the categorical defense against the topgainer v1-v54 bug — even if
|
||||||
|
the BarHistoryProvider were leaky, this wrapper refuses to carry forward
|
||||||
|
any T+0 information into the screener.
|
||||||
|
"""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
decision_date: dt.date
|
||||||
|
last_bar_date: dt.date
|
||||||
|
last_close: float
|
||||||
|
high_252d_max: float # max(high[T-252..T-2]); excludes last_bar_date by construction
|
||||||
|
high_252d_max_window: list[dt.date] = field(default_factory=list)
|
||||||
|
volume_t_minus_1: float = 0.0
|
||||||
|
median_volume_20d_t_minus_2: float = 0.0
|
||||||
|
atr_14_t_minus_1: float = 0.0
|
||||||
|
atr_normalized_t_minus_1: float = 0.0
|
||||||
|
avg_dollar_volume_20d: float = 0.0
|
||||||
|
last_bar_timestamp: dt.datetime | None = None # tz-aware
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
# 1) Forbidden field substrings on user-supplied extras.
|
||||||
|
for k in self.extra.keys():
|
||||||
|
kl = str(k).lower()
|
||||||
|
for forbidden in _FORBIDDEN_T0_FIELD_SUBSTRINGS:
|
||||||
|
if forbidden in kl:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"FrozenT1Features for {self.symbol}: field {k!r} contains "
|
||||||
|
f"forbidden substring {forbidden!r} — these encode entry-day "
|
||||||
|
"data and constitute a categorical look-ahead"
|
||||||
|
)
|
||||||
|
# 2) last_bar_date must be strictly before decision_date.
|
||||||
|
if self.last_bar_date >= self.decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"FrozenT1Features for {self.symbol}: last_bar_date {self.last_bar_date.isoformat()} "
|
||||||
|
f"is not strictly before decision_date {self.decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
# 3) high_252d_max_window dates must be strictly before decision_date.
|
||||||
|
for d in self.high_252d_max_window:
|
||||||
|
if d >= self.decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"FrozenT1Features for {self.symbol}: 252d window includes "
|
||||||
|
f"{d.isoformat()} which is not strictly before "
|
||||||
|
f"{self.decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
# 4) last_bar_timestamp (if provided) must be strictly before 09:30 ET on decision_date.
|
||||||
|
if self.last_bar_timestamp is not None:
|
||||||
|
_assert_features_strictly_before_decision_open(
|
||||||
|
self.symbol, self.decision_date, [self.last_bar_timestamp]
|
||||||
|
)
|
||||||
|
|
||||||
|
def __getattr__(self, item: str) -> Any: # pragma: no cover - defensive
|
||||||
|
# Only invoked if normal attribute lookup fails, but we want to be
|
||||||
|
# explicit about forbidden access patterns even on dynamic getattr.
|
||||||
|
kl = item.lower()
|
||||||
|
for forbidden in _FORBIDDEN_T0_FIELD_SUBSTRINGS:
|
||||||
|
if forbidden in kl:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"FrozenT1Features for {self.symbol}: access to {item!r} blocked — "
|
||||||
|
f"contains forbidden substring {forbidden!r}"
|
||||||
|
)
|
||||||
|
raise AttributeError(item)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pure trigger feature computations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def compute_52w_high_breakout(
|
||||||
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
||||||
|
*,
|
||||||
|
lookback_days: int = 252,
|
||||||
|
) -> tuple[bool, float, float, list[dt.date]]:
|
||||||
|
"""Return (is_breakout, last_close, prior_max_high, used_window_dates).
|
||||||
|
|
||||||
|
``bars`` must be chronologically ordered AND strictly before the decision_date.
|
||||||
|
The "prior 252-day high" is computed over the [-(lookback+1) .. -2] slice —
|
||||||
|
i.e. the 252 days BEFORE T-1 — so T-1's own high never enters the max.
|
||||||
|
Returns ``(False, last_close, 0.0, [])`` on insufficient history.
|
||||||
|
"""
|
||||||
|
if len(bars) < 2:
|
||||||
|
return False, 0.0, 0.0, []
|
||||||
|
last_date, last_bar = bars[-1]
|
||||||
|
last_close = float(last_bar.get("close", 0.0))
|
||||||
|
if last_close <= 0:
|
||||||
|
return False, 0.0, 0.0, []
|
||||||
|
|
||||||
|
# Window = the 252 bars BEFORE T-1 (excludes T-1 itself).
|
||||||
|
prior_window = bars[-(lookback_days + 1):-1]
|
||||||
|
if len(prior_window) < max(20, lookback_days // 4):
|
||||||
|
# Need at least a minimal window to claim a 52w high.
|
||||||
|
return False, last_close, 0.0, []
|
||||||
|
|
||||||
|
used_dates = [d for d, _ in prior_window]
|
||||||
|
prior_max_high = max(float(b.get("high", 0.0)) for _, b in prior_window)
|
||||||
|
is_breakout = last_close > prior_max_high
|
||||||
|
return bool(is_breakout), last_close, float(prior_max_high), used_dates
|
||||||
|
|
||||||
|
|
||||||
|
def compute_volume_ratio(
|
||||||
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
||||||
|
*,
|
||||||
|
median_window: int = 20,
|
||||||
|
) -> tuple[float | None, float | None]:
|
||||||
|
"""Return (volume_T-1, median_volume_20d_T-2).
|
||||||
|
|
||||||
|
Median is computed over the 20 bars BEFORE T-1 — i.e. ending at T-2.
|
||||||
|
Returns (None, None) on insufficient data.
|
||||||
|
"""
|
||||||
|
if len(bars) < median_window + 1:
|
||||||
|
return None, None
|
||||||
|
last_volume = float(bars[-1][1].get("volume", 0.0))
|
||||||
|
prior_window = bars[-(median_window + 1):-1]
|
||||||
|
prior_volumes = [float(b.get("volume", 0.0)) for _, b in prior_window]
|
||||||
|
if not prior_volumes:
|
||||||
|
return None, None
|
||||||
|
median_vol = float(statistics.median(prior_volumes))
|
||||||
|
return last_volume, median_vol
|
||||||
|
|
||||||
|
|
||||||
|
def compute_atr_normalized(
|
||||||
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
||||||
|
*,
|
||||||
|
window: int = 14,
|
||||||
|
) -> float | None:
|
||||||
|
"""Compute ATR_14 / close_T-1 from the last 15 bars (need T-15..T-1)."""
|
||||||
|
if len(bars) < window + 1:
|
||||||
|
return None
|
||||||
|
recent = bars[-(window + 1):]
|
||||||
|
trs: list[float] = []
|
||||||
|
prev_close = float(recent[0][1].get("close", 0.0))
|
||||||
|
for d, bar in recent[1:]:
|
||||||
|
high = float(bar.get("high", 0.0))
|
||||||
|
low = float(bar.get("low", 0.0))
|
||||||
|
close = float(bar.get("close", 0.0))
|
||||||
|
tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
|
||||||
|
trs.append(tr)
|
||||||
|
prev_close = close
|
||||||
|
if not trs:
|
||||||
|
return None
|
||||||
|
atr = statistics.fmean(trs)
|
||||||
|
last_close = float(bars[-1][1].get("close", 0.0))
|
||||||
|
if last_close <= 0:
|
||||||
|
return None
|
||||||
|
return atr / last_close
|
||||||
|
|
||||||
|
|
||||||
|
def compute_avg_dollar_volume_20d(
|
||||||
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
||||||
|
) -> float:
|
||||||
|
"""Mean(close * volume) over the last 20 bars."""
|
||||||
|
if not bars:
|
||||||
|
return 0.0
|
||||||
|
tail = bars[-20:]
|
||||||
|
if not tail:
|
||||||
|
return 0.0
|
||||||
|
return statistics.fmean(
|
||||||
|
float(b.get("close", 0.0)) * float(b.get("volume", 0.0))
|
||||||
|
for _, b in tail
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trigger evaluation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VolBreakout52wTriggerInputs:
|
||||||
|
"""Bundle of T-1-or-earlier inputs for one (symbol, decision_date) trigger.
|
||||||
|
|
||||||
|
NOTE: ``last_bar_date`` MUST be strictly before ``decision_date``. The
|
||||||
|
builder enforces this and ``evaluate_trigger`` re-asserts as defence in depth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
decision_date: dt.date
|
||||||
|
next_trading_date: dt.date
|
||||||
|
last_bar_date: dt.date
|
||||||
|
last_bar_timestamp: dt.datetime # tz-aware
|
||||||
|
last_close: float
|
||||||
|
prior_252d_max_high: float
|
||||||
|
is_52w_breakout: bool
|
||||||
|
volume_t_minus_1: float
|
||||||
|
median_volume_20d_t_minus_2: float
|
||||||
|
atr_normalized_t_minus_1: float
|
||||||
|
avg_dollar_volume_20d: float
|
||||||
|
pre_open_gap_pct: float | None # may be None when missing-data path is taken
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_trigger(
|
||||||
|
inputs: VolBreakout52wTriggerInputs,
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
"""Pure trigger check. Returns (passes, reject_reason).
|
||||||
|
|
||||||
|
Defence-in-depth: re-assert ``last_bar_date < decision_date`` so any future
|
||||||
|
code path that bypasses the BarHistoryProvider boundary still trips here.
|
||||||
|
"""
|
||||||
|
if inputs.last_bar_date >= inputs.decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"VolBreakout52w {inputs.symbol}: last_bar_date {inputs.last_bar_date.isoformat()} "
|
||||||
|
f"is not strictly before decision_date {inputs.decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Universe gates — ADV and price.
|
||||||
|
adv_min = float(getattr(engine, "vol_breakout_52w_min_avg_dollar_volume", 10_000_000.0) or 0.0)
|
||||||
|
if adv_min > 0 and inputs.avg_dollar_volume_20d < adv_min:
|
||||||
|
return False, (
|
||||||
|
f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} "
|
||||||
|
f"< min {adv_min:,.0f}"
|
||||||
|
)
|
||||||
|
price_min = float(getattr(engine, "vol_breakout_52w_min_price", 5.0) or 0.0)
|
||||||
|
if price_min > 0 and inputs.last_close < price_min:
|
||||||
|
return False, f"last_close {inputs.last_close:.2f} < min price {price_min:.2f}"
|
||||||
|
|
||||||
|
# 1) 52-week breakout.
|
||||||
|
if not inputs.is_52w_breakout:
|
||||||
|
return False, (
|
||||||
|
f"close {inputs.last_close:.4f} <= prior 252d max high "
|
||||||
|
f"{inputs.prior_252d_max_high:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) Volume confirmation.
|
||||||
|
vol_ratio_min = float(getattr(engine, "vol_breakout_52w_volume_ratio_min", 2.0) or 0.0)
|
||||||
|
if inputs.median_volume_20d_t_minus_2 <= 0:
|
||||||
|
return False, "median_volume_20d_t_minus_2 <= 0"
|
||||||
|
actual_ratio = inputs.volume_t_minus_1 / inputs.median_volume_20d_t_minus_2
|
||||||
|
if actual_ratio < vol_ratio_min:
|
||||||
|
return False, (
|
||||||
|
f"volume_ratio {actual_ratio:.3f} < min {vol_ratio_min:.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) ATR / close band — filter parabolics AND too-quiet stocks.
|
||||||
|
atr_min = float(getattr(engine, "vol_breakout_52w_atr_normalized_min", 0.015) or 0.0)
|
||||||
|
atr_max = float(getattr(engine, "vol_breakout_52w_atr_normalized_max", 0.06) or 1.0)
|
||||||
|
if inputs.atr_normalized_t_minus_1 < atr_min:
|
||||||
|
return False, (
|
||||||
|
f"atr_normalized {inputs.atr_normalized_t_minus_1:.4f} < min {atr_min:.4f}"
|
||||||
|
)
|
||||||
|
if inputs.atr_normalized_t_minus_1 > atr_max:
|
||||||
|
return False, (
|
||||||
|
f"atr_normalized {inputs.atr_normalized_t_minus_1:.4f} > max {atr_max:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) Pre-open gap fade guard.
|
||||||
|
gap_max = float(getattr(engine, "vol_breakout_52w_pre_open_gap_max", 0.04) or 0.0)
|
||||||
|
if inputs.pre_open_gap_pct is not None and gap_max > 0:
|
||||||
|
if inputs.pre_open_gap_pct > gap_max:
|
||||||
|
return False, (
|
||||||
|
f"pre_open_gap_pct {inputs.pre_open_gap_pct:.4f} > max {gap_max:.4f}"
|
||||||
|
)
|
||||||
|
# If pre_open_gap_pct is None, the missing-data path was already chosen at the
|
||||||
|
# builder level (skip-with-warning vs. hard-fail).
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_candidates(
|
||||||
|
decision_date: dt.date,
|
||||||
|
next_trading_date: dt.date,
|
||||||
|
universe_symbols: Iterable[str],
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
bar_provider: BarHistoryProvider,
|
||||||
|
pre_open_gap_provider: PreOpenGapProvider | None = None,
|
||||||
|
*,
|
||||||
|
_missing_gap_warned: dict[str, bool] | None = None,
|
||||||
|
) -> list[Candidate]:
|
||||||
|
"""Construct synthetic VolBreakout52w candidates for ``next_trading_date`` execution.
|
||||||
|
|
||||||
|
Decision logic runs at T-1 close (=decision_date close); orders fill at
|
||||||
|
T+1 next_open. Every input must satisfy ``timestamp < decision_date 09:30 ET``.
|
||||||
|
|
||||||
|
``pre_open_gap_provider`` is optional. When None, behavior depends on
|
||||||
|
``engine.vol_breakout_52w_skip_if_no_gap_data``:
|
||||||
|
* True → skip the gap guard (no enforcement) and log a one-shot warning.
|
||||||
|
* False → do not enforce (no enforcement) and log a one-shot warning.
|
||||||
|
Either way the engine emits candidates without the gap guard. A loud
|
||||||
|
one-shot log surfaces the missing infra.
|
||||||
|
"""
|
||||||
|
if not getattr(engine, "vol_breakout_52w_enabled", False):
|
||||||
|
return []
|
||||||
|
if next_trading_date <= decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"VolBreakout52w next_trading_date {next_trading_date.isoformat()} must be "
|
||||||
|
f"strictly after decision_date {decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
lookback = int(getattr(engine, "vol_breakout_52w_lookback_days", 252) or 252)
|
||||||
|
median_window = int(getattr(engine, "vol_breakout_52w_volume_median_window", 20) or 20)
|
||||||
|
skip_if_no_gap = bool(getattr(engine, "vol_breakout_52w_skip_if_no_gap_data", False))
|
||||||
|
|
||||||
|
# One-shot missing-gap warning aggregation. Caller may pass a shared dict.
|
||||||
|
warned = _missing_gap_warned if _missing_gap_warned is not None else {}
|
||||||
|
if pre_open_gap_provider is None and not warned.get("logged"):
|
||||||
|
if skip_if_no_gap:
|
||||||
|
logger.warning(
|
||||||
|
"vol_breakout_52w_pre_open_gap_provider_missing",
|
||||||
|
action="skip_gap_guard",
|
||||||
|
detail=(
|
||||||
|
"PreOpenGapProvider not wired; the +4% gap-fade guard is INACTIVE. "
|
||||||
|
"Backtest results will under-penalize gap-up days. Mark all derived "
|
||||||
|
"PnL as 'missing pre-open guard'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"vol_breakout_52w_pre_open_gap_provider_missing",
|
||||||
|
action="no_enforcement_no_skip",
|
||||||
|
detail="PreOpenGapProvider not wired and skip flag is False — gap guard inactive.",
|
||||||
|
)
|
||||||
|
warned["logged"] = True
|
||||||
|
|
||||||
|
candidates: list[Candidate] = []
|
||||||
|
seen_symbols: set[str] = set()
|
||||||
|
|
||||||
|
# Need enough bars for both the 252d window and the 20d volume median.
|
||||||
|
fetch_lookback = max(lookback + 5, median_window + 5)
|
||||||
|
|
||||||
|
for raw_symbol in universe_symbols:
|
||||||
|
symbol = str(raw_symbol).strip().upper()
|
||||||
|
if not symbol or symbol in seen_symbols:
|
||||||
|
continue
|
||||||
|
seen_symbols.add(symbol)
|
||||||
|
|
||||||
|
bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=fetch_lookback)
|
||||||
|
if not bars:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Strict T-1 check: most recent allowed bar must be < decision_date.
|
||||||
|
last_bar_date, last_bar = bars[-1]
|
||||||
|
if last_bar_date >= decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"VolBreakout52w bar for {symbol} on {last_bar_date.isoformat()} is not "
|
||||||
|
f"strictly before decision_date {decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_close = float(last_bar.get("close", 0.0))
|
||||||
|
if last_close <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --- Cheap universe gates first to short-circuit before the 252d scan ---
|
||||||
|
adv_min = float(getattr(engine, "vol_breakout_52w_min_avg_dollar_volume", 10_000_000.0) or 0.0)
|
||||||
|
price_min = float(getattr(engine, "vol_breakout_52w_min_price", 5.0) or 0.0)
|
||||||
|
if price_min > 0 and last_close < price_min:
|
||||||
|
continue
|
||||||
|
adv_20d = compute_avg_dollar_volume_20d(bars)
|
||||||
|
if adv_min > 0 and adv_20d < adv_min:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_breakout, _last_close_check, prior_max_high, used_dates = compute_52w_high_breakout(
|
||||||
|
bars, lookback_days=lookback
|
||||||
|
)
|
||||||
|
# Defence-in-depth: every used date in the 252d window must be strictly < decision_date.
|
||||||
|
for d in used_dates:
|
||||||
|
if d >= decision_date:
|
||||||
|
raise LookaheadViolationError(
|
||||||
|
f"VolBreakout52w 252d window for {symbol} includes {d.isoformat()} "
|
||||||
|
f"which is not strictly before decision_date {decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
if not is_breakout:
|
||||||
|
continue
|
||||||
|
|
||||||
|
vol_t1, median_vol_t2 = compute_volume_ratio(bars, median_window=median_window)
|
||||||
|
if vol_t1 is None or median_vol_t2 is None or median_vol_t2 <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
atr_norm = compute_atr_normalized(bars, window=14)
|
||||||
|
if atr_norm is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pre-open gap (optional).
|
||||||
|
pre_open_gap_pct: float | None = None
|
||||||
|
if pre_open_gap_provider is not None:
|
||||||
|
try:
|
||||||
|
pre_open_gap_pct = pre_open_gap_provider.get_pre_open_gap_pct(
|
||||||
|
symbol=symbol,
|
||||||
|
next_trading_date=next_trading_date,
|
||||||
|
prev_close=last_close,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug(
|
||||||
|
"vol_breakout_52w_pre_open_gap_provider_error",
|
||||||
|
symbol=symbol,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
pre_open_gap_pct = None
|
||||||
|
|
||||||
|
last_bar_ts = _bar_close_timestamp(last_bar_date)
|
||||||
|
# Hot-path lookahead assertion.
|
||||||
|
_assert_features_strictly_before_decision_open(
|
||||||
|
symbol, decision_date, [last_bar_ts]
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs = VolBreakout52wTriggerInputs(
|
||||||
|
symbol=symbol,
|
||||||
|
decision_date=decision_date,
|
||||||
|
next_trading_date=next_trading_date,
|
||||||
|
last_bar_date=last_bar_date,
|
||||||
|
last_bar_timestamp=last_bar_ts,
|
||||||
|
last_close=last_close,
|
||||||
|
prior_252d_max_high=prior_max_high,
|
||||||
|
is_52w_breakout=is_breakout,
|
||||||
|
volume_t_minus_1=vol_t1,
|
||||||
|
median_volume_20d_t_minus_2=median_vol_t2,
|
||||||
|
atr_normalized_t_minus_1=atr_norm,
|
||||||
|
avg_dollar_volume_20d=adv_20d,
|
||||||
|
pre_open_gap_pct=pre_open_gap_pct,
|
||||||
|
)
|
||||||
|
|
||||||
|
passes, reason = evaluate_trigger(inputs, engine)
|
||||||
|
if not passes:
|
||||||
|
logger.debug(
|
||||||
|
"vol_breakout_52w_trigger_skipped",
|
||||||
|
symbol=symbol,
|
||||||
|
decision_date=decision_date.isoformat(),
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = _build_candidate_from_inputs(inputs, engine)
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Candidate construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_candidate_from_inputs(
|
||||||
|
inputs: VolBreakout52wTriggerInputs,
|
||||||
|
engine: StrategyEngineConfig,
|
||||||
|
) -> Candidate:
|
||||||
|
# Map pct exits onto the existing ATR-multiplier / R-multiple machinery.
|
||||||
|
synthetic_atr = max(inputs.last_close * 0.02, 0.01)
|
||||||
|
stop_pct = float(getattr(engine, "vol_breakout_52w_stop_pct", 0.03) or 0.03)
|
||||||
|
target_pct = float(getattr(engine, "vol_breakout_52w_target_pct", 0.05) or 0.05)
|
||||||
|
stop_mult = stop_pct / 0.02 if stop_pct > 0 else 1.5
|
||||||
|
target_r = target_pct / stop_pct if stop_pct > 0 else 1.67
|
||||||
|
max_holding_days = max(1, int(getattr(engine, "vol_breakout_52w_max_holding_days", 2) or 2))
|
||||||
|
|
||||||
|
# Score: deterministic function of the volume spike — higher conviction at higher ratio.
|
||||||
|
if inputs.median_volume_20d_t_minus_2 > 0:
|
||||||
|
vol_ratio = inputs.volume_t_minus_1 / inputs.median_volume_20d_t_minus_2
|
||||||
|
else:
|
||||||
|
vol_ratio = 1.0
|
||||||
|
score = 0.5 + 0.05 * (vol_ratio - 2.0)
|
||||||
|
score = max(0.0, min(0.99, score))
|
||||||
|
score_bucket = (
|
||||||
|
"high" if score >= 0.8
|
||||||
|
else "medium_high" if score >= 0.6
|
||||||
|
else "medium"
|
||||||
|
)
|
||||||
|
|
||||||
|
event_id = (
|
||||||
|
f"synth_vol_breakout_52w_{inputs.symbol.lower()}_"
|
||||||
|
f"{inputs.decision_date.isoformat()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
features = {
|
||||||
|
"vol_breakout_52w_decision_date": inputs.decision_date.isoformat(),
|
||||||
|
"vol_breakout_52w_last_close": inputs.last_close,
|
||||||
|
"vol_breakout_52w_prior_252d_max_high": inputs.prior_252d_max_high,
|
||||||
|
"vol_breakout_52w_volume_t_minus_1": inputs.volume_t_minus_1,
|
||||||
|
"vol_breakout_52w_median_volume_20d_t_minus_2": inputs.median_volume_20d_t_minus_2,
|
||||||
|
"vol_breakout_52w_volume_ratio": round(vol_ratio, 4),
|
||||||
|
"vol_breakout_52w_atr_normalized_t_minus_1": round(inputs.atr_normalized_t_minus_1, 6),
|
||||||
|
"vol_breakout_52w_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
|
||||||
|
"vol_breakout_52w_pre_open_gap_pct": inputs.pre_open_gap_pct,
|
||||||
|
"vol_breakout_52w_stop_pct": stop_pct,
|
||||||
|
"vol_breakout_52w_target_pct": target_pct,
|
||||||
|
"vol_breakout_52w_max_holding_days": max_holding_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Candidate(
|
||||||
|
event_id=event_id,
|
||||||
|
symbol=inputs.symbol,
|
||||||
|
source_symbol=inputs.symbol,
|
||||||
|
score=score,
|
||||||
|
sector="UNKNOWN",
|
||||||
|
event_type=VOL_BREAKOUT_52W_EVENT_TYPE,
|
||||||
|
event_timestamp=inputs.last_bar_timestamp,
|
||||||
|
event_date=inputs.decision_date,
|
||||||
|
filing_time_bucket="post_market",
|
||||||
|
timing_class="after_close",
|
||||||
|
reaction_date=inputs.decision_date,
|
||||||
|
execution_date=inputs.next_trading_date,
|
||||||
|
entry_price_est=inputs.last_close,
|
||||||
|
avg_dollar_volume=inputs.avg_dollar_volume_20d,
|
||||||
|
atr_14=synthetic_atr,
|
||||||
|
score_bucket=score_bucket,
|
||||||
|
engine_id=engine.engine_id,
|
||||||
|
entry_timing_policy="next_open",
|
||||||
|
trade_direction="long",
|
||||||
|
engine_max_holding_days=max_holding_days,
|
||||||
|
engine_risk_budget_pct=engine.engine_risk_budget_pct,
|
||||||
|
engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
|
||||||
|
engine_target_1_r=target_r,
|
||||||
|
engine_target_1_fraction=1.0,
|
||||||
|
engine_trailing_model=engine.trailing_model_override,
|
||||||
|
engine_trailing_warmup_days=engine.trailing_warmup_days_override,
|
||||||
|
engine_stop_atr_multiplier=stop_mult,
|
||||||
|
engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
|
||||||
|
engine_use_reaction_day_low_stop=False,
|
||||||
|
engine_early_failure_close_below_entry_and_reaction_close=False,
|
||||||
|
engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override,
|
||||||
|
engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override,
|
||||||
|
engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override,
|
||||||
|
shadow_only=engine.shadow_only,
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adapters: bridge BacktestRunner state to the Protocols above.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SnapshotStoreBarAdapter:
|
||||||
|
"""Adapt SnapshotStore (or any bars-by-symbol-by-date dict) to BarHistoryProvider.
|
||||||
|
|
||||||
|
Caches the sorted (date, bar) list per symbol so the per-day loop does not
|
||||||
|
re-sort O(B) bars on each call. This is the hot path for VolBreakout52w
|
||||||
|
because the universe is scanned daily, unlike event-triggered engines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]
|
||||||
|
_sorted_cache: dict[str, list[tuple[dt.date, dict[str, Any]]]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def _sorted_for(self, symbol: str) -> list[tuple[dt.date, dict[str, Any]]]:
|
||||||
|
sym_upper = symbol.upper()
|
||||||
|
cached = self._sorted_cache.get(sym_upper)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
sym_bars = self.bars_by_symbol.get(sym_upper)
|
||||||
|
if not sym_bars:
|
||||||
|
self._sorted_cache[sym_upper] = []
|
||||||
|
return self._sorted_cache[sym_upper]
|
||||||
|
ordered = sorted(sym_bars.items(), key=lambda kv: kv[0])
|
||||||
|
self._sorted_cache[sym_upper] = ordered
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
def get_bars_before(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
as_of_date: dt.date,
|
||||||
|
lookback_days: int,
|
||||||
|
) -> list[tuple[dt.date, dict[str, Any]]]:
|
||||||
|
ordered = self._sorted_for(symbol)
|
||||||
|
if not ordered:
|
||||||
|
return []
|
||||||
|
# Binary scan would be faster but we cap lookback small, so linear-from-end is fine.
|
||||||
|
# Strictly before as_of_date.
|
||||||
|
eligible: list[tuple[dt.date, dict[str, Any]]] = []
|
||||||
|
for d, b in ordered:
|
||||||
|
if d >= as_of_date:
|
||||||
|
break
|
||||||
|
eligible.append((d, b))
|
||||||
|
return eligible[-lookback_days:]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"VOL_BREAKOUT_52W_EVENT_TYPE",
|
||||||
|
"BarHistoryProvider",
|
||||||
|
"FrozenT1Features",
|
||||||
|
"PreOpenGapProvider",
|
||||||
|
"VolBreakout52wTriggerInputs",
|
||||||
|
"_SnapshotStoreBarAdapter",
|
||||||
|
"_assert_features_strictly_before_decision_open",
|
||||||
|
"build_candidates",
|
||||||
|
"compute_52w_high_breakout",
|
||||||
|
"compute_atr_normalized",
|
||||||
|
"compute_avg_dollar_volume_20d",
|
||||||
|
"compute_volume_ratio",
|
||||||
|
"evaluate_trigger",
|
||||||
|
]
|
||||||
@ -0,0 +1,629 @@
|
|||||||
|
"""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
|
||||||
@ -0,0 +1,983 @@
|
|||||||
|
"""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."
|
||||||
|
)
|
||||||
@ -0,0 +1,900 @@
|
|||||||
|
"""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
|
||||||
Loading…
Reference in New Issue