"""Morning Momentum Intraday Backtester — Main Entry Point. Usage: # Default run (sp500, 40 trading days, default params) python -m apps.intraday_bt.run # Override params inline python -m apps.intraday_bt.run --days 200 --universe midlarge --top-n 5 --stop-loss -0.03 # Custom config file python -m apps.intraday_bt.run --config configs/intraday/default.yaml # Parameter sweep python -m apps.intraday_bt.run --sweep configs/intraday/sweep_basic.yaml # Cache management python -m apps.intraday_bt.run --no-cache python -m apps.intraday_bt.run --refresh-cache # Verbose daily output python -m apps.intraday_bt.run --verbose """ from __future__ import annotations import argparse import asyncio import json import pickle import re import sys import uuid from datetime import date, timedelta from pathlib import Path import yaml from apps.intraday_bt.oracle import make_intraday_oracle_client from libs.common.config import get_settings from libs.common.time_utils import is_trading_day, to_eastern, trading_days_between, utc_now from libs.intraday.cache import DailyBarCache, IntradayCache from libs.intraday.catalyst import ( AttentionEventCache, FilingEventCache, fetch_attention_features_bulk, fetch_filing_event_features_bulk, ) from libs.intraday.domain import ( BacktestParams, CacheParams, IntradayConfig, ORBStrategyParams, OutputParams, StrategyParams, UniverseParams, ) from libs.intraday.features import compute_gap_pct, enrich_daily_bars from libs.intraday.metrics import ( compute_metrics, format_daily_breakdown, format_summary, format_sweep_comparison, format_top_trades, write_results, ) from libs.intraday.screener import ( fetch_daily_bars_bulk, fetch_intraday_bulk, momentum_intraday_first_candidates, momentum_pre_screen_candidates, orb_pre_screen_candidates, pre_screen_candidates, resolve_universe, ) from libs.intraday.simulator import ( SECTOR_PROXY_TICKERS, _bar_at_offset, _dollar_volume_up_to_bar, _market_open_ts, _volume_up_to_bar, filter_market_hours, run_simulation, ) from libs.oracle_client import CompanyService from libs.oracle_client.fred import FredService # ── Config Loading ───────────────────────────────────────────────────────── def load_config(path: str | None) -> IntradayConfig: """Load config from YAML file or return defaults.""" if path is None: default_path = Path("configs/intraday/default.yaml") if default_path.exists(): path = str(default_path) else: return IntradayConfig() with open(path) as f: raw = yaml.safe_load(f) or {} strategy_mode = raw.get("strategy_mode", "momentum") strategy = StrategyParams(**raw.get("strategy", {})) universe = UniverseParams(**raw.get("universe", {})) backtest = BacktestParams(**raw.get("backtest", {})) cache = CacheParams(**raw.get("cache", {})) output = OutputParams(**raw.get("output", {})) orb_strategy = None if strategy_mode == "orb": orb_strategy = ORBStrategyParams(**raw.get("orb_strategy", {})) return IntradayConfig( strategy_mode=strategy_mode, strategy=strategy, orb_strategy=orb_strategy, universe=universe, backtest=backtest, cache=cache, output=output, ) def apply_cli_overrides(config: IntradayConfig, args: argparse.Namespace) -> IntradayConfig: """Apply CLI argument overrides to config.""" strategy_dict = config.strategy.model_dump() universe_dict = config.universe.model_dump() backtest_dict = config.backtest.model_dump() cache_dict = config.cache.model_dump() output_dict = config.output.model_dump() if args.days is not None: backtest_dict["lookback_trading_days"] = args.days if getattr(args, "start", None) is not None: backtest_dict["start_date"] = args.start if getattr(args, "end", None) is not None: backtest_dict["end_date"] = args.end if args.universe is not None: universe_dict["source"] = args.universe if args.top_n is not None: strategy_dict["top_n"] = args.top_n if args.stop_loss is not None: strategy_dict["stop_loss_pct"] = None if args.stop_loss.lower() == "none" else float(args.stop_loss) if args.entry_min is not None: strategy_dict["entry_minutes_after_open"] = args.entry_min if args.exit_min is not None: strategy_dict["exit_minutes_before_close"] = args.exit_min if args.min_gain is not None: strategy_dict["min_morning_gain_pct"] = args.min_gain if args.no_cache: cache_dict["enabled"] = False if args.verbose: output_dict["verbose"] = True if args.output_dir is not None: output_dict["dir"] = args.output_dir strategy_mode = config.strategy_mode if hasattr(args, "strategy") and args.strategy is not None: strategy_mode = args.strategy orb_strategy = config.orb_strategy if strategy_mode == "orb" and orb_strategy is None: orb_strategy = ORBStrategyParams() # Override compound_returns / initial_capital / daily_budget_reset via CLI compound_override = getattr(args, "compound_returns", None) capital_override = getattr(args, "initial_capital", None) reset_override = getattr(args, "daily_budget_reset", None) if strategy_mode == "orb" and orb_strategy is not None: if compound_override is not None or capital_override is not None or reset_override is not None: orb_dict = orb_strategy.model_dump() if compound_override is not None: orb_dict["compound_returns"] = compound_override if capital_override is not None: orb_dict["initial_capital"] = capital_override if reset_override is not None: orb_dict["daily_budget_reset"] = reset_override orb_strategy = ORBStrategyParams(**orb_dict) elif strategy_mode != "orb": if compound_override is not None or capital_override is not None or reset_override is not None: if compound_override is not None: strategy_dict["compound_returns"] = compound_override if capital_override is not None: strategy_dict["initial_capital"] = capital_override if reset_override is not None: strategy_dict["daily_budget_reset"] = reset_override return IntradayConfig( strategy_mode=strategy_mode, strategy=StrategyParams(**strategy_dict), orb_strategy=orb_strategy, universe=UniverseParams(**universe_dict), backtest=BacktestParams(**backtest_dict), cache=CacheParams(**cache_dict), output=OutputParams(**output_dict), ) # ── Trading Day Resolution ───────────────────────────────────────────────── async def get_trading_days( client: OracleClient, start_date: str | None, end_date: str | None, lookback: int, ) -> list[str]: """Resolve the list of trading days for the backtest period.""" # Use the local NYSE calendar for deterministic date resolution. # This avoids long-range Oracle timeouts when resolving multi-year periods. today = _latest_backtest_date() if end_date: end = date.fromisoformat(end_date) else: end = today if start_date: start = date.fromisoformat(start_date) else: # Fetch extra calendar days to account for weekends/holidays. start = end - timedelta(days=lookback * 2) days = [d.isoformat() for d in trading_days_between(start, end)] # If an explicit start_date was pinned, return all days in range (no lookback cap) if start_date: return days # Otherwise return last N trading days return days[-lookback:] def _latest_backtest_date(now_et=None) -> date: """Return the latest completed date safe for historical backtests. - Trading day after 16:00 ET: include today - Trading day before 16:00 ET: stop at yesterday - Non-trading day: use today as upper bound; the NYSE calendar trims to the last completed trading session automatically. """ if now_et is None: now_et = to_eastern(utc_now()) today = now_et.date() if not is_trading_day(today) or now_et.hour >= 16: return today return today - timedelta(days=1) def _latest_completed_trading_day(now_et=None) -> date: """Return the most recent completed trading session date.""" latest = _latest_backtest_date(now_et) while not is_trading_day(latest): latest -= timedelta(days=1) return latest def _load_ticker_sectors(tickers: list[str]) -> dict[str, str]: """Load cached sector labels for intraday basket diversification filters.""" settings = get_settings() path = Path(settings.data_root) / "cache" / "sector_cache.json" if not path.exists(): return {ticker: "UNKNOWN" for ticker in tickers} try: payload = json.loads(path.read_text()) except Exception: return {ticker: "UNKNOWN" for ticker in tickers} return {ticker: str(payload.get(ticker) or "UNKNOWN") for ticker in tickers} def _write_ticker_sector_cache(payload: dict[str, str]) -> None: settings = get_settings() path = Path(settings.data_root) / "cache" / "sector_cache.json" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(dict(sorted(payload.items())), indent=2)) def _sector_info_is_placeholder(info: object) -> bool: sector = getattr(info, "sector", None) industry = getattr(info, "industry", None) exchange = getattr(info, "exchange", None) market_cap = getattr(info, "market_cap", None) return ( sector == "Technology" and industry == "Software" and exchange is None and market_cap is None ) async def _load_ticker_sectors_with_oracle( tickers: list[str], client, *, concurrency: int = 12, ) -> dict[str, str]: """Load sector labels from cache, backfilling missing values from Oracle.""" if not tickers: return {} result = _load_ticker_sectors(tickers) missing = sorted( ticker for ticker, sector in result.items() if not sector or str(sector).upper() == "UNKNOWN" ) if not missing or client is None: return result settings = get_settings() path = Path(settings.data_root) / "cache" / "sector_cache.json" try: cache_payload = json.loads(path.read_text()) if path.exists() else {} except Exception: cache_payload = {} semaphore = asyncio.Semaphore(max(1, concurrency)) company_svc = CompanyService(client) async def _fetch_sector(ticker: str) -> tuple[str, str | None]: async with semaphore: try: info = await company_svc.get_company(ticker) except Exception: return ticker, None if _sector_info_is_placeholder(info): return ticker, None sector = str(getattr(info, "sector", None) or "").strip() if not sector or sector.upper() == "UNKNOWN": return ticker, None return ticker, sector updated = False tasks = [asyncio.create_task(_fetch_sector(ticker)) for ticker in missing] for task in asyncio.as_completed(tasks): ticker, sector = await task if not sector: continue result[ticker] = sector cache_payload[ticker] = sector updated = True if updated: _write_ticker_sector_cache(cache_payload) return result def _load_orb_ticker_sectors(tickers: list[str]) -> dict[str, str]: """Backward-compatible alias for ORB paths.""" return _load_ticker_sectors(tickers) def _orb_strategy_uses_catalyst(params: ORBStrategyParams) -> bool: return ( params.engine_family == "stocks_in_play_dual_regime" or params.require_event_flag or params.weight_event_catalyst > 0 ) def _orb_strategy_uses_attention(params: ORBStrategyParams) -> bool: return ( params.engine_family == "stocks_in_play_dual_regime" or params.weight_attention_wiki > 0 or params.weight_attention_news > 0 or params.attention_min_wiki_spike_10d is not None or params.attention_min_wiki_zscore_20d is not None or params.attention_min_article_count_3d is not None or params.attention_min_us_article_count_3d is not None or params.attention_min_resolver_confidence is not None ) def _orb_strategy_uses_vix(params: ORBStrategyParams) -> bool: return any( value is not None and value != default for value, default in [ (params.max_vix, None), (params.vix_size_scale_low, None), (params.vix_size_scale_high, None), ] ) or params.vix_size_scale_min != 1.0 async def _prefetch_prior_event_features_db( tickers: list[str], trading_days: list[str], lookback_calendar_days: int = 7, event_types: tuple[str, ...] = ("earnings_release", "guidance_update"), ) -> dict[str, dict[str, dict]]: """Bulk-fetch prior earnings/guidance events from DB and build event feature map. For each ticker, finds events of the specified types in the DB events table within [start - lookback_calendar_days, end], then marks each trading day within `lookback_calendar_days` after an event as event_flag=True, event_score=1.0. Trading days with no recent event retain event_flag=False, event_score=0.0. """ import asyncpg from datetime import datetime, timedelta if not tickers or not trading_days: return {} start_date = trading_days[0] end_date = trading_days[-1] # Expand lookback window to capture events before the first trading day start_dt = datetime.strptime(start_date, "%Y-%m-%d").date() end_dt = datetime.strptime(end_date, "%Y-%m-%d").date() # symbol_id format: SYM::{TICKER}::US symbol_ids = [f"SYM::{t}::US" for t in tickers] ticker_from_sid = {f"SYM::{t}::US": t for t in tickers} dsn = get_settings().postgres_dsn.replace("+asyncpg", "") conn = await asyncpg.connect(dsn=dsn) try: rows = await conn.fetch( """ SELECT symbol_id, event_date::text AS event_date FROM events WHERE symbol_id = ANY($1) AND event_type = ANY($2) AND event_date BETWEEN $3 AND $4 ORDER BY symbol_id, event_date """, symbol_ids, list(event_types), (start_dt - timedelta(days=lookback_calendar_days)), end_dt, ) finally: await conn.close() # Group event dates per ticker from collections import defaultdict events_by_ticker: dict[str, list[str]] = defaultdict(list) for row in rows: ticker = ticker_from_sid.get(row["symbol_id"]) if ticker: events_by_ticker[ticker].append(row["event_date"]) # Build feature map: for each trading day, mark True if within lookback window after an event result: dict[str, dict[str, dict]] = {} for ticker in tickers: event_dates = events_by_ticker.get(ticker, []) if not event_dates: continue ticker_map: dict[str, dict] = {} for td_str in trading_days: td = datetime.strptime(td_str, "%Y-%m-%d").date() # Check if any event falls in [td - lookback_calendar_days, td - 1] has_prior_event = False for ev_str in event_dates: ev = datetime.strptime(ev_str, "%Y-%m-%d").date() days_ago = (td - ev).days if 1 <= days_ago <= lookback_calendar_days: has_prior_event = True break if has_prior_event: ticker_map[td_str] = {"event_flag": True, "event_score": 1.0} if ticker_map: result[ticker] = ticker_map return result def _merge_orb_event_features( enrichment: dict[str, dict[str, dict]], event_features: dict[str, dict[str, dict]], ) -> None: for ticker, day_map in event_features.items(): ticker_enrichment = enrichment.setdefault(ticker, {}) for day, features in day_map.items(): ticker_day = ticker_enrichment.setdefault(day, {}) ticker_day.update(features) def _merge_orb_attention_features( enrichment: dict[str, dict[str, dict]], attention_features: dict[str, dict[str, dict]], ) -> None: for ticker, day_map in attention_features.items(): ticker_enrichment = enrichment.setdefault(ticker, {}) for day, features in day_map.items(): ticker_day = ticker_enrichment.setdefault(day, {}) ticker_day.update(features) def _orb_candidate_event_tickers( candidates: dict[str, list[str]], enrichment: dict[str, dict[str, dict]], params: ORBStrategyParams, ) -> list[str]: """Reduce catalyst fetches to the most relevant daily stocks-in-play names.""" selected: set[str] = set() min_abs_gap = getattr(params, "min_abs_gap_pct", None) per_day_limit = max(int(getattr(params, "max_candidates", 20) * 2), 12) for day, day_tickers in candidates.items(): ranked: list[tuple[float, str]] = [] for ticker in day_tickers: ticker_day = enrichment.get(ticker, {}).get(day, {}) prev_close = ticker_day.get("prev_close") today_open = ticker_day.get("today_open") if prev_close and today_open and prev_close > 0: abs_gap = abs((today_open - prev_close) / prev_close) if min_abs_gap is not None and abs_gap < min_abs_gap: continue ranked.append((abs_gap, ticker)) ranked.sort(reverse=True) for _, ticker in ranked[:per_day_limit]: selected.add(ticker) return sorted(selected) def _orb_candidate_event_pairs( candidates: dict[str, list[str]], enrichment: dict[str, dict[str, dict]], params: ORBStrategyParams, ) -> list[tuple[str, str]]: selected: list[tuple[str, str]] = [] min_abs_gap = getattr(params, "min_abs_gap_pct", None) per_day_limit = max(int(getattr(params, "max_candidates", 20) * 2), 12) for day, day_tickers in candidates.items(): ranked: list[tuple[float, str]] = [] for ticker in day_tickers: ticker_day = enrichment.get(ticker, {}).get(day, {}) prev_close = ticker_day.get("prev_close") today_open = ticker_day.get("today_open") if prev_close and today_open and prev_close > 0: abs_gap = abs((today_open - prev_close) / prev_close) if min_abs_gap is not None and abs_gap < min_abs_gap: continue ranked.append((abs_gap, ticker)) ranked.sort(reverse=True) for _, ticker in ranked[:per_day_limit]: selected.append((ticker, day)) return selected def _momentum_strategy_uses_daily_enrichment(params: StrategyParams) -> bool: return any( value is not None and value != default for value, default in [ (params.min_gap_pct, None), (params.max_gap_pct, None), (params.min_volume_ratio_14d, None), (params.min_ret_5d, None), (params.min_entropy_20d, None), (params.max_entropy_20d, None), (params.entropy_size_scale_low, None), (params.entropy_size_scale_high, None), (params.use_moderate_gap_liquid_sleeve, False), (params.use_liquid_cluster_engine, False), (params.use_sector_etf_sleeve, False), (params.liquid_cluster_require_special_liquidity_gate, False), (params.liquid_cluster_min_avg_dollar_vol_30d, None), (params.liquid_cluster_max_avg_dollar_vol_30d, None), (params.liquid_cluster_min_volume_ratio_14d, None), (params.liquid_cluster_max_entropy_20d, None), (params.candidate_seed_moderate_liquid_overlay_slots, 0), (params.candidate_intraday_moderate_liquid_reserve_slots, 0), (params.market_regime_gap_threshold, None), (params.regime_size_scale_low, None), (params.regime_size_scale_high, None), (params.regime_skip_below, None), (params.min_candidate_breadth, None), (params.breadth_size_scale_low, None), (params.breadth_size_scale_high, None), (params.breadth_skip_below, None), ] ) or params.use_five_sleeves def _momentum_strategy_uses_sector_labels(params: StrategyParams) -> bool: return bool( params.max_positions_per_sector or params.use_sector_thrust_sleeve or params.use_liquid_cluster_engine or params.use_sector_etf_sleeve ) def _momentum_strategy_uses_sector_proxies(params: StrategyParams) -> bool: return bool( params.use_sector_etf_sleeve and params.sector_etf_capital_fraction > 0 and params.sector_etf_max_positions > 0 ) def _momentum_strategy_requires_regime_ticker_daily(params: StrategyParams) -> bool: return any( value is not None and value != default for value, default in [ (params.market_regime_gap_threshold, None), (params.regime_size_scale_low, None), (params.regime_size_scale_high, None), (params.regime_skip_below, None), ] ) def _momentum_strategy_uses_catalyst(params: StrategyParams) -> bool: return ( params.candidate_require_event_flag or params.candidate_min_event_score is not None or bool(params.candidate_allowed_event_types) or params.candidate_seed_event_overlay_slots > 0 or params.candidate_seed_event_min_score is not None or params.candidate_weight_event_score > 0 or params.candidate_intraday_weight_event_score > 0 or params.candidate_intraday_event_reserve_slots > 0 or params.candidate_intraday_event_reserve_min_score is not None or params.use_event_sleeve or params.event_weight > 0 or params.event_min_score is not None or params.use_event_day_liquid_sleeve or params.event_day_liquid_min_event_score is not None or params.event_day_liquid_min_event_support_score is not None or params.event_day_liquid_min_total_event_entry_dollar_volume is not None ) def _momentum_strategy_uses_candidate_stage_catalyst(params: StrategyParams) -> bool: return ( params.candidate_require_event_flag or params.candidate_min_event_score is not None or bool(params.candidate_allowed_event_types) or params.candidate_seed_event_overlay_slots > 0 or params.candidate_seed_event_min_score is not None ) def _momentum_candidate_allowed_event_types(strategy: StrategyParams) -> set[str]: return { str(value).strip().lower() for value in strategy.candidate_allowed_event_types if str(value).strip() } def _momentum_candidate_event_types_pass(info: dict, strategy: StrategyParams) -> bool: allowed_event_types = _momentum_candidate_allowed_event_types(strategy) if not allowed_event_types: return True raw_event_types = info.get("event_types") or [] event_types = { str(value).strip().lower() for value in raw_event_types if str(value).strip() } if not event_types: return False return any(event_type in allowed_event_types for event_type in event_types) def _momentum_strategy_uses_attention(params: StrategyParams) -> bool: return ( params.candidate_weight_attention_wiki > 0 or params.candidate_weight_attention_news > 0 or params.candidate_intraday_weight_attention_wiki > 0 or params.candidate_intraday_weight_attention_news > 0 or params.candidate_min_attention_wiki_spike_10d is not None or params.candidate_min_attention_article_count_3d is not None or params.candidate_min_attention_us_article_count_3d is not None or params.candidate_min_attention_resolver_confidence is not None ) def _momentum_strategy_uses_vix(params: StrategyParams) -> bool: return any( value is not None and value != default for value, default in [ (params.max_vix, None), (params.vix_size_scale_low, None), (params.vix_size_scale_high, None), ] ) or params.vix_size_scale_min != 1.0 def _momentum_uses_historical_intraday_first(params: StrategyParams) -> bool: return str(getattr(params, "candidate_source_mode", "daily_gap")).lower() == "intraday_first" def _should_use_recent_live_scan( strategy: StrategyParams, trading_days: list[str], ) -> bool: if strategy.recent_live_scan_days <= 0 or not trading_days: return False if len(trading_days) > strategy.recent_live_scan_days: return False latest_safe = _latest_backtest_date() end_day = date.fromisoformat(trading_days[-1]) delta_days = (latest_safe - end_day).days return 0 <= delta_days <= strategy.recent_live_scan_days def _recent_live_scan_universe(strategy: StrategyParams) -> UniverseParams: return UniverseParams( source="screener", market_cap_min=strategy.recent_live_scan_market_cap_min, avg_volume_min=strategy.recent_live_scan_avg_volume_min, min_price=strategy.recent_live_scan_min_price, ) def _strategy_for_recent_live_scan( strategy: StrategyParams, *, recent_live_scan: bool, ) -> StrategyParams: """Apply recent-window-only overrides without affecting research/Q1 configs.""" if not recent_live_scan: return strategy updates: dict[str, object] = {} override_fields = [ ("recent_live_scan_top_n", "top_n"), ("recent_live_scan_min_morning_gain_pct", "min_morning_gain_pct"), ("recent_live_scan_max_morning_gain_pct", "max_morning_gain_pct"), ("recent_live_scan_min_confirmation_return_pct", "min_confirmation_return_pct"), ("recent_live_scan_min_entry_dollar_volume", "min_entry_dollar_volume"), ("recent_live_scan_max_gap_pct", "max_gap_pct"), ("recent_live_scan_max_entropy_20d", "max_entropy_20d"), ("recent_live_scan_use_slow_ignite_sleeve", "use_slow_ignite_sleeve"), ("recent_live_scan_slow_ignite_weight", "slow_ignite_weight"), ("recent_live_scan_slow_ignite_min_gain_pct", "slow_ignite_min_gain_pct"), ("recent_live_scan_slow_ignite_max_gain_pct", "slow_ignite_max_gain_pct"), ("recent_live_scan_slow_ignite_min_entry_dollar_volume", "slow_ignite_min_entry_dollar_volume"), ("recent_live_scan_slow_ignite_max_entropy_20d", "slow_ignite_max_entropy_20d"), ("recent_live_scan_use_liquid_largecap_sleeve", "use_liquid_largecap_sleeve"), ("recent_live_scan_liquid_largecap_weight", "liquid_largecap_weight"), ("recent_live_scan_liquid_largecap_min_gain_pct", "liquid_largecap_min_gain_pct"), ("recent_live_scan_liquid_largecap_max_gain_pct", "liquid_largecap_max_gain_pct"), ("recent_live_scan_liquid_largecap_min_confirmation_return_pct", "liquid_largecap_min_confirmation_return_pct"), ("recent_live_scan_liquid_largecap_min_entry_dollar_volume", "liquid_largecap_min_entry_dollar_volume"), ("recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d", "liquid_largecap_min_avg_dollar_vol_30d"), ("recent_live_scan_liquid_largecap_max_entropy_20d", "liquid_largecap_max_entropy_20d"), ] for source_field, target_field in override_fields: value = getattr(strategy, source_field, None) if value is not None: updates[target_field] = value if not updates: return strategy return strategy.model_copy(update=updates) def _should_use_recent_intraday_first_scan(trading_days: list[str]) -> bool: """Recent live-scan windows should use intraday-first candidate generation. Using daily-first on 1-5 day sanity windows misses obvious leaders such as recent Yahoo gainers that are absent from the static research universe or do not pass a daily pre-screen despite strong same-day intraday momentum. """ return bool(trading_days) def _merge_momentum_event_features( enrichment: dict[str, dict[str, dict]], event_features: dict[str, dict[str, dict]], ) -> None: for ticker, day_map in event_features.items(): ticker_enrichment = enrichment.setdefault(ticker, {}) for day, features in day_map.items(): ticker_day = ticker_enrichment.setdefault(day, {}) ticker_day.update(features) def _merge_momentum_attention_features( enrichment: dict[str, dict[str, dict]], attention_features: dict[str, dict[str, dict]], ) -> None: for ticker, day_map in attention_features.items(): ticker_enrichment = enrichment.setdefault(ticker, {}) for day, features in day_map.items(): ticker_day = ticker_enrichment.setdefault(day, {}) ticker_day.update(features) def _momentum_preliminary_candidates( daily_bars: dict[str, list[dict]], trading_days: list[str], enrichment: dict[str, dict[str, dict]], threshold: float, strategy: StrategyParams, ) -> dict[str, list[str]]: return momentum_pre_screen_candidates( daily_bars, trading_days, enrichment, threshold=threshold, max_per_day=None, strategy=None, ) def _momentum_intraday_seed_candidates( daily_bars: dict[str, list[dict]], trading_days: list[str], enrichment: dict[str, dict[str, dict]], strategy: StrategyParams, *, default_threshold: float, use_signal_features: bool = False, ) -> dict[str, list[str]]: seed_threshold = strategy.candidate_seed_threshold seed_max_per_day = strategy.candidate_seed_max_per_day if not _momentum_uses_historical_intraday_first(strategy): seed_threshold = default_threshold seed_max_per_day = max(strategy.top_n * 20, 120) return momentum_pre_screen_candidates( daily_bars, trading_days, enrichment, threshold=seed_threshold, max_per_day=seed_max_per_day, strategy=strategy if use_signal_features else None, ) def _augment_momentum_seed_candidates_with_liquid_overlay( candidates: dict[str, list[str]], daily_bars: dict[str, list[dict]], trading_days: list[str], enrichment: dict[str, dict[str, dict]], strategy: StrategyParams, ) -> tuple[dict[str, list[str]], dict[str, set[str]]]: """Returns (augmented_candidates, overlay_tickers_per_day). overlay_tickers_per_day maps each date to the set of tickers added by the overlay (leader + liquid + event). Used by the ORB path to bypass gapper-specific filters for these tickers. """ slots = int(getattr(strategy, "candidate_seed_liquid_overlay_slots", 0) or 0) leader_slots = int(getattr(strategy, "candidate_seed_leader_overlay_slots", 0) or 0) moderate_slots = int(getattr(strategy, "candidate_seed_moderate_liquid_overlay_slots", 0) or 0) event_slots = int(getattr(strategy, "candidate_seed_event_overlay_slots", 0) or 0) if slots <= 0 and leader_slots <= 0 and moderate_slots <= 0 and event_slots <= 0: return {day: list(day_tickers) for day, day_tickers in candidates.items() if day_tickers}, {} ticker_day_bar: dict[str, dict[str, dict]] = {} for ticker, bars in daily_bars.items(): day_map: dict[str, dict] = {} for bar in bars: day_map[str(bar["date"])[:10]] = bar ticker_day_bar[ticker] = day_map result: dict[str, list[str]] = {} overlay_by_day: dict[str, set[str]] = {} min_gap = getattr(strategy, "candidate_seed_liquid_min_gap_pct", None) max_gap = getattr(strategy, "candidate_seed_liquid_max_gap_pct", None) min_avg_dollar_vol = getattr(strategy, "candidate_seed_liquid_min_avg_dollar_vol_30d", None) min_ret_5d = getattr(strategy, "candidate_seed_liquid_min_ret_5d", None) max_entropy = getattr(strategy, "candidate_seed_liquid_max_entropy_20d", None) leader_min_gap = getattr(strategy, "candidate_seed_leader_min_gap_pct", None) leader_max_gap = getattr(strategy, "candidate_seed_leader_max_gap_pct", None) leader_min_avg_dollar_vol = getattr(strategy, "candidate_seed_leader_min_avg_dollar_vol_30d", None) leader_min_ret_5d = getattr(strategy, "candidate_seed_leader_min_ret_5d", None) leader_min_atr_pct = getattr(strategy, "candidate_seed_leader_min_atr_pct", None) leader_max_entropy = getattr(strategy, "candidate_seed_leader_max_entropy_20d", None) moderate_min_gap = getattr(strategy, "candidate_seed_moderate_liquid_min_gap_pct", None) moderate_max_gap = getattr(strategy, "candidate_seed_moderate_liquid_max_gap_pct", None) moderate_min_avg_dollar_vol = getattr( strategy, "candidate_seed_moderate_liquid_min_avg_dollar_vol_30d", None, ) moderate_max_avg_dollar_vol = getattr( strategy, "candidate_seed_moderate_liquid_max_avg_dollar_vol_30d", None, ) moderate_min_ret_5d = getattr(strategy, "candidate_seed_moderate_liquid_min_ret_5d", None) moderate_max_entropy = getattr(strategy, "candidate_seed_moderate_liquid_max_entropy_20d", None) event_min_score = getattr(strategy, "candidate_seed_event_min_score", None) event_min_gap = getattr(strategy, "candidate_seed_event_min_gap_pct", None) event_max_gap = getattr(strategy, "candidate_seed_event_max_gap_pct", None) event_min_avg_dollar_vol = getattr(strategy, "candidate_seed_event_min_avg_dollar_vol_30d", None) event_min_ret_5d = getattr(strategy, "candidate_seed_event_min_ret_5d", None) event_max_entropy = getattr(strategy, "candidate_seed_event_max_entropy_20d", None) for day in trading_days: day_candidates = list(candidates.get(day, [])) chosen = set(day_candidates) day_overlay_tickers: set[str] = set() overlay_ranked: list[tuple[float, float, float, str]] = [] for ticker, date_map in ticker_day_bar.items(): if ticker in chosen: continue if day not in date_map: continue info = enrichment.get(ticker, {}).get(day, {}) gap_pct = info.get("gap_pct") if gap_pct is None: continue if min_gap is not None and gap_pct < min_gap: continue if max_gap is not None and gap_pct > max_gap: continue avg_dollar_vol = info.get("avg_dollar_vol_30d") if min_avg_dollar_vol is not None and ( avg_dollar_vol is None or avg_dollar_vol < min_avg_dollar_vol ): continue ret_5d = info.get("ret_5d") if min_ret_5d is not None and (ret_5d is None or ret_5d < min_ret_5d): continue entropy_20d = info.get("entropy_20d") if max_entropy is not None and ( entropy_20d is None or entropy_20d > max_entropy ): continue overlay_ranked.append( ( float(avg_dollar_vol or 0.0), float(gap_pct or 0.0), float(ret_5d or 0.0), ticker, ) ) if overlay_ranked: ranked_overlay = [ticker for *_rest, ticker in sorted(overlay_ranked, reverse=True)[:slots]] for ticker in ranked_overlay: if ticker not in chosen: day_candidates.append(ticker) chosen.add(ticker) day_overlay_tickers.add(ticker) moderate_ranked: list[tuple[float, float, float, float, str]] = [] for ticker, date_map in ticker_day_bar.items(): if ticker in chosen: continue if day not in date_map: continue info = enrichment.get(ticker, {}).get(day, {}) gap_pct = info.get("gap_pct") if moderate_min_gap is not None and (gap_pct is None or gap_pct < moderate_min_gap): continue if moderate_max_gap is not None and (gap_pct is None or gap_pct > moderate_max_gap): continue avg_dollar_vol = info.get("avg_dollar_vol_30d") if moderate_min_avg_dollar_vol is not None and ( avg_dollar_vol is None or avg_dollar_vol < moderate_min_avg_dollar_vol ): continue if moderate_max_avg_dollar_vol is not None and ( avg_dollar_vol is None or avg_dollar_vol > moderate_max_avg_dollar_vol ): continue ret_5d = info.get("ret_5d") if moderate_min_ret_5d is not None and (ret_5d is None or ret_5d < moderate_min_ret_5d): continue entropy_20d = info.get("entropy_20d") if moderate_max_entropy is not None and ( entropy_20d is None or entropy_20d > moderate_max_entropy ): continue moderate_ranked.append( ( float(gap_pct or 0.0), float(avg_dollar_vol or 0.0), float(ret_5d or 0.0), -float(entropy_20d if entropy_20d is not None else 1.0), ticker, ) ) if moderate_ranked: ranked_moderate = [ ticker for *_rest, ticker in sorted(moderate_ranked, reverse=True)[:moderate_slots] ] for ticker in ranked_moderate: if ticker not in chosen: day_candidates.append(ticker) chosen.add(ticker) day_overlay_tickers.add(ticker) enrichment.setdefault(ticker, {}).setdefault(day, {})[ "candidate_seed_moderate_liquid_overlay" ] = True event_ranked: list[tuple[float, float, float, float, float, str]] = [] for ticker, date_map in ticker_day_bar.items(): if ticker in chosen: continue if day not in date_map: continue info = enrichment.get(ticker, {}).get(day, {}) if not bool(info.get("event_flag")): continue if not _momentum_candidate_event_types_pass(info, strategy): continue event_score = float(info.get("event_score") or 0.0) if event_min_score is not None and event_score < float(event_min_score): continue gap_pct = info.get("gap_pct") if event_min_gap is not None and (gap_pct is None or gap_pct < event_min_gap): continue if event_max_gap is not None and (gap_pct is None or gap_pct > event_max_gap): continue avg_dollar_vol = info.get("avg_dollar_vol_30d") if event_min_avg_dollar_vol is not None and ( avg_dollar_vol is None or avg_dollar_vol < event_min_avg_dollar_vol ): continue ret_5d = info.get("ret_5d") if event_min_ret_5d is not None and (ret_5d is None or ret_5d < event_min_ret_5d): continue entropy_20d = info.get("entropy_20d") if event_max_entropy is not None and ( entropy_20d is None or entropy_20d > event_max_entropy ): continue event_ranked.append( ( event_score, float(avg_dollar_vol or 0.0), float(gap_pct or 0.0), float(ret_5d or 0.0), -float(entropy_20d if entropy_20d is not None else 1.0), ticker, ) ) if event_ranked: ranked_events = [ ticker for *_rest, ticker in sorted(event_ranked, reverse=True)[:event_slots] ] for ticker in ranked_events: if ticker not in chosen: day_candidates.append(ticker) chosen.add(ticker) day_overlay_tickers.add(ticker) leader_ranked: list[tuple[float, float, float, float, str]] = [] for ticker, date_map in ticker_day_bar.items(): if ticker in chosen: continue bar = date_map.get(day) if not bar: continue info = enrichment.get(ticker, {}).get(day, {}) gap_pct = info.get("gap_pct") if leader_min_gap is not None and (gap_pct is None or gap_pct < leader_min_gap): continue if leader_max_gap is not None and (gap_pct is None or gap_pct > leader_max_gap): continue avg_dollar_vol = info.get("avg_dollar_vol_30d") if leader_min_avg_dollar_vol is not None and ( avg_dollar_vol is None or avg_dollar_vol < leader_min_avg_dollar_vol ): continue ret_5d = info.get("ret_5d") if leader_min_ret_5d is not None and (ret_5d is None or ret_5d < leader_min_ret_5d): continue entropy_20d = info.get("entropy_20d") if leader_max_entropy is not None and ( entropy_20d is None or entropy_20d > leader_max_entropy ): continue atr_14 = info.get("atr_14") open_price = bar.get("open") atr_pct = ( float(atr_14) / float(open_price) if atr_14 is not None and open_price not in (None, 0) else None ) if leader_min_atr_pct is not None and (atr_pct is None or atr_pct < leader_min_atr_pct): continue leader_ranked.append( ( float(ret_5d or 0.0), float(avg_dollar_vol or 0.0), float(atr_pct or 0.0), -float(entropy_20d or 1.0), ticker, ) ) if leader_ranked: ranked_leaders = [ ticker for *_rest, ticker in sorted(leader_ranked, reverse=True)[:leader_slots] ] for ticker in ranked_leaders: if ticker not in chosen: day_candidates.append(ticker) chosen.add(ticker) day_overlay_tickers.add(ticker) if day_candidates: result[day] = day_candidates if day_overlay_tickers: overlay_by_day[day] = day_overlay_tickers return result, overlay_by_day def _normalize_candidate_map( candidates: dict[str, list[str]] | tuple[dict[str, list[str]], object], ) -> dict[str, list[str]]: """Normalize helper output to a plain {day: tickers} map. Some call sites work with helpers that return `(candidate_map, metadata)`. Normalizing here keeps run/sweep code robust and avoids shape drift across paths. """ if isinstance(candidates, tuple): candidates = candidates[0] return { day: list(day_tickers) for day, day_tickers in candidates.items() if day_tickers } def _momentum_candidate_event_tickers( candidates: dict[str, list[str]], ) -> list[str]: selected: set[str] = set() for day_tickers in candidates.values(): selected.update(day_tickers) return sorted(selected) def _momentum_candidate_event_pairs( candidates: dict[str, list[str]], enrichment: dict[str, dict[str, dict]], strategy: StrategyParams, ) -> list[tuple[str, str]]: per_day_limit = max(strategy.top_n * 15, 60) selected: list[tuple[str, str]] = [] for day, day_tickers in candidates.items(): ranked = sorted( day_tickers, key=lambda ticker: ( float(enrichment.get(ticker, {}).get(day, {}).get("gap_pct") or 0.0), float(enrichment.get(ticker, {}).get(day, {}).get("ret_5d") or 0.0), -float(enrichment.get(ticker, {}).get(day, {}).get("entropy_20d") or 1.0), float(enrichment.get(ticker, {}).get(day, {}).get("avg_dollar_vol_30d") or 0.0), ), reverse=True, ) for ticker in ranked[:per_day_limit]: selected.append((ticker, day)) return selected def _recent_intraday_first_candidates( all_intraday: dict[str, dict[str, list[dict]]], trading_days: list[str], strategy: StrategyParams, ) -> dict[str, list[str]]: """Build a recent-day candidate shortlist directly from intraday action. This path is only used for very recent sanity windows where a static universe and daily-first pre-screen can miss obvious day leaders (for example names that are absent from the research universe but present in today's Yahoo gainers list). """ result: dict[str, list[str]] = {} shortlist_size = max(strategy.top_n * 25, strategy.recent_live_scan_max_candidates_per_day) volume_gate = max(100_000, int((strategy.min_entry_volume or 0) * 0.5)) liquid_dollar_gate = max( 50_000_000.0, float(strategy.min_entry_dollar_volume or 0.0) * 10.0, ) largecap_dollar_gate = max(liquid_dollar_gate, 100_000_000.0) for day in trading_days: bars_by_ticker = all_intraday.get(day, {}) if not bars_by_ticker: continue market_open = _market_open_ts(day) fast_scored: list[tuple[float, float, str]] = [] largecap_scored: list[tuple[float, float, float, str]] = [] liquid_scored: list[tuple[float, float, float, str]] = [] slow_scored: list[tuple[float, float, float, str]] = [] for ticker, all_bars in bars_by_ticker.items(): mkt_bars = filter_market_hours(all_bars) if len(mkt_bars) < 5: continue open_price = float(mkt_bars[0].get("open", 0.0) or 0.0) if open_price <= 0: continue entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) if entry_bar is None: continue entry_price = float(entry_bar.get("close", 0.0) or 0.0) if entry_price <= 0: continue gain_pct = (entry_price - open_price) / open_price confirmation_bar = entry_bar if strategy.confirmation_minutes_after_entry > 0: confirmation_bar = _bar_at_offset( mkt_bars, market_open, strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry, ) if confirmation_bar is None: continue confirmation_price = float(confirmation_bar.get("close", 0.0) or 0.0) if confirmation_price <= 0: continue confirmation_gain_pct = (confirmation_price - open_price) / open_price confirmation_return = (confirmation_price - entry_price) / entry_price if entry_price > 0 else 0.0 entry_ts = _parse_et_bar_timestamp(confirmation_bar["timestamp"]) entry_volume = _volume_up_to_bar(mkt_bars, entry_ts) if entry_volume < volume_gate: continue entry_dollar_volume = _dollar_volume_up_to_bar(mkt_bars, entry_ts) if gain_pct > 0: fast_scored.append((gain_pct, entry_volume, ticker)) if ( confirmation_gain_pct >= 0.002 and confirmation_return >= -0.001 and entry_dollar_volume >= largecap_dollar_gate ): largecap_scored.append((entry_dollar_volume, confirmation_return, confirmation_gain_pct, ticker)) if ( confirmation_gain_pct >= 0.003 and confirmation_return >= 0.0 and entry_dollar_volume >= liquid_dollar_gate ): liquid_scored.append((entry_dollar_volume, confirmation_gain_pct, confirmation_return, ticker)) if ( confirmation_gain_pct >= 0.003 and confirmation_gain_pct < max(strategy.min_morning_gain_pct, 0.015) and confirmation_return >= 0.0 and entry_dollar_volume >= liquid_dollar_gate ): slow_scored.append((confirmation_return, entry_dollar_volume, confirmation_gain_pct, ticker)) if not fast_scored and not largecap_scored and not liquid_scored and not slow_scored: continue fast_slots = max(strategy.top_n * 8, int(shortlist_size * 0.45)) largecap_slots = max(strategy.top_n * 4, int(shortlist_size * 0.20)) liquid_slots = max(strategy.top_n * 4, int(shortlist_size * 0.20)) slow_slots = max(strategy.top_n * 2, shortlist_size - fast_slots - largecap_slots - liquid_slots) ranked_fast = [ticker for _gain, _vol, ticker in sorted(fast_scored, reverse=True)[:fast_slots]] ranked_largecap = [ ticker for _dvol, _conf_ret, _gain, ticker in sorted(largecap_scored, reverse=True)[:largecap_slots] ] ranked_liquid = [ ticker for _dvol, _gain, _conf, ticker in sorted(liquid_scored, reverse=True)[:liquid_slots] ] ranked_slow = [ ticker for _conf, _dvol, _gain, ticker in sorted(slow_scored, reverse=True)[:slow_slots] ] merged: list[str] = [] for source in (ranked_fast, ranked_largecap, ranked_liquid, ranked_slow): for ticker in source: if ticker not in merged: merged.append(ticker) if len(merged) >= shortlist_size: break if len(merged) >= shortlist_size: break if merged: result[day] = merged return result def _retain_recent_intraday_shortlist( candidates: dict[str, list[str]], daily_bars: dict[str, list[dict]], *, require_daily_features: bool, ) -> dict[str, list[str]]: """Keep the intraday-first shortlist instead of overwriting it with daily pre-screen. When momentum strategies need daily enrichment, recent same-day leaders still need a cached daily history row for gap/entropy/trend features. Otherwise retain the intraday-first shortlist as-is. """ if not require_daily_features: return {day: list(day_tickers) for day, day_tickers in candidates.items() if day_tickers} retained: dict[str, list[str]] = {} for day, day_tickers in candidates.items(): kept = [ticker for ticker in day_tickers if ticker in daily_bars] if kept: retained[day] = kept return retained def _parse_et_bar_timestamp(timestamp: str): from libs.intraday.simulator import _parse_ts return _parse_ts(timestamp) async def _fetch_vix_by_day( client: OracleClient, trading_days: list[str], ) -> dict[str, float]: if not trading_days: return {} try: if not await client.health_check_fast(): return _load_vix_from_local_macro_snapshots(trading_days) except Exception: return _load_vix_from_local_macro_snapshots(trading_days) try: fred = FredService(client) response = await fred.get_observations("VIXCLS", start=trading_days[0], end=trading_days[-1]) result: dict[str, float] = {} for obs in response.observations: if obs.value is None: continue result[obs.date] = float(obs.value) if result: return result except Exception: pass return _load_vix_from_local_macro_snapshots(trading_days) _MACRO_WINDOW_RE = re.compile(r"macro_window_(\d{4}-\d{2}-\d{2})_(\d{4}-\d{2}-\d{2})\.pkl$") def _load_vix_from_local_macro_snapshots(trading_days: list[str]) -> dict[str, float]: """Best-effort local fallback when Oracle/FRED is unavailable. Several backtester workflows persist macro_window_*.pkl files with point-in-time VIX values. Reusing them keeps intraday research and official backtests from hard-failing when the FRED proxy is temporarily down. """ if not trading_days: return {} settings = get_settings() root = Path(settings.data_root) / "parquet" if not root.exists(): return {} start = trading_days[0] end = trading_days[-1] best_path: Path | None = None best_span: int | None = None for path in root.rglob("macro_window_*.pkl"): match = _MACRO_WINDOW_RE.search(path.name) if not match: continue window_start, window_end = match.groups() if window_start > start or window_end < end: continue span = (date.fromisoformat(window_end) - date.fromisoformat(window_start)).days if best_span is None or span < best_span: best_span = span best_path = path if best_path is None: return {} try: with best_path.open("rb") as fh: payload = pickle.load(fh) except Exception: return {} if not isinstance(payload, dict): return {} result: dict[str, float] = {} for key, values in payload.items(): if not isinstance(values, dict): continue vix_value = values.get("VIXCLS") if vix_value is None: continue if hasattr(key, "isoformat"): key_str = key.isoformat() else: key_str = str(key) if start <= key_str <= end: result[key_str] = float(vix_value) return result def _momentum_enrichment_for_days( daily_bars: dict[str, list[dict]], trading_days: list[str], ) -> dict[str, dict[str, dict]]: enrichment = enrich_daily_bars(daily_bars, trading_days) for ticker, day_map in enrichment.items(): ticker_bars = sorted(daily_bars.get(ticker, []), key=lambda bar: bar["date"]) by_day = {bar["date"][:10]: bar for bar in ticker_bars} for day, features in day_map.items(): prev_close = features.get("prev_close") today_open = features.get("today_open") features["gap_pct"] = ( compute_gap_pct(prev_close, today_open) if prev_close is not None and today_open is not None else None ) today_bar = by_day.get(day) if today_bar and today_bar.get("volume") and features.get("avg_daily_vol_14d"): avg_daily_vol = features["avg_daily_vol_14d"] features["daily_volume_ratio_14d"] = ( today_bar["volume"] / avg_daily_vol if avg_daily_vol and avg_daily_vol > 0 else None ) else: features["daily_volume_ratio_14d"] = None return enrichment # ── Progress Reporting ───────────────────────────────────────────────────── def _make_progress_bar(completed: int, total: int, width: int = 30) -> str: pct = completed / total if total > 0 else 0 filled = int(width * pct) bar = "█" * filled + "░" * (width - filled) return f"[{bar}] {completed}/{total} ({pct*100:.0f}%)" def _chunk_trading_days_by_pairs( trading_days: list[str], candidates: dict[str, list[str]], max_pairs_per_chunk: int = 5_000, ) -> list[list[str]]: """Split trading days into contiguous chunks capped by candidate pair count. ORB single-run backtests previously loaded every candidate day's 5-minute bars into memory before simulation. Large windows can exceed multiple GB in Python objects, so we stream a few days at a time instead. """ chunks: list[list[str]] = [] current: list[str] = [] current_pairs = 0 for day in trading_days: day_pairs = len(candidates.get(day, [])) if current and current_pairs + day_pairs > max_pairs_per_chunk: chunks.append(current) current = [] current_pairs = 0 current.append(day) current_pairs += day_pairs if current: chunks.append(current) return chunks # ── Main Orchestrator ────────────────────────────────────────────────────── async def run(config: IntradayConfig, refresh_cache: bool = False) -> tuple: """Full pipeline: universe → daily bars → intraday bars → simulate → metrics. Returns (day_results, metrics, all_intraday, trading_days). """ settings = get_settings() is_orb = config.strategy_mode == "orb" if is_orb: p = config.orb_strategy or ORBStrategyParams() timeout_minutes = 9 * 60 + 30 + p.order_timeout_minutes timeout_hour, timeout_minute = divmod(timeout_minutes, 60) print( f"\nORB | {config.universe.source} | ${p.initial_capital:,.0f} | " f"last {config.backtest.lookback_trading_days}d | " f"orb:{p.orb_minutes}min stop:{p.atr_stop_multiplier}xATR " f"be:{p.breakeven_at_r}R tr:{p.trailing_at_r}R timeout:{timeout_hour:02d}:{timeout_minute:02d} " f"rvol:{p.min_rvol} risk:{p.risk_per_trade_pct*100:.2f}%" ) else: s = config.strategy print( f"\nMoMo | {config.universe.source} | ${s.initial_capital:,.0f} | " f"last {config.backtest.lookback_trading_days}d | " f"entry:+{s.entry_minutes_after_open}min exit:-{s.exit_minutes_before_close}min " f"stop:{s.stop_loss_pct or 'off'} gain:{s.min_morning_gain_pct*100:.1f}% top:{s.top_n}" ) cache = IntradayCache(config.cache.dir) if config.cache.enabled else None daily_cache = ( DailyBarCache(str(Path(config.cache.dir).with_name("daily"))) if config.cache.enabled else None ) event_cache = ( FilingEventCache(str(Path(config.cache.dir).with_name("orb_catalyst"))) if config.cache.enabled else None ) attention_cache = ( AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention"))) if config.cache.enabled else None ) attention_cache = ( AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention"))) if config.cache.enabled else None ) if refresh_cache and cache: print("\n Refreshing cache (evicting all entries)...") removed = cache.evict() print(f" Removed {removed} cached files.") if cache: stats = cache.stats() print(f"\n Cache: {stats['total_files']} files, {stats['total_mb']} MB") async with make_intraday_oracle_client(settings) as client: # Step 1: Get trading days print("[1/4] Resolving trading calendar...") trading_days = await get_trading_days( client, config.backtest.start_date, config.backtest.end_date, config.backtest.lookback_trading_days, ) print(f" {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)") # Step 2: Resolve universe recent_live_scan = (not is_orb) and _should_use_recent_live_scan(config.strategy, trading_days) if not is_orb: config = config.model_copy( update={"strategy": _strategy_for_recent_live_scan(config.strategy, recent_live_scan=recent_live_scan)} ) universe_params = _recent_live_scan_universe(config.strategy) if recent_live_scan else config.universe universe_label = ( f"{config.universe.source} + recent-live-scan" if recent_live_scan else config.universe.source ) recent_intraday_first_scan = recent_live_scan and _should_use_recent_intraday_first_scan(trading_days) print(f"[2/4] Resolving universe ({universe_label})...") tickers = await resolve_universe(universe_params, client) print(f" {len(tickers)} tickers") if recent_intraday_first_scan: print( f"[3/4] Phase 1: Fetching intraday bars for recent live scan " f"({len(tickers)} tickers across {len(trading_days)} days)..." ) intraday_seed = {day: tickers for day in trading_days} _live_last_pct = [-1] def live_intraday_progress(completed: int, total: int, hits: int, calls: int) -> None: if completed == 0 and calls == 0 and total > 0: sys.stdout.write("\n") sys.stdout.flush() _live_last_pct[0] = -1 pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _live_last_pct[0] or completed == total: _live_last_pct[0] = pct sys.stdout.write( f"\r {_make_progress_bar(completed, total)} cache:{hits} api:{calls}" ) sys.stdout.flush() all_intraday = await fetch_intraday_bulk( intraday_seed, client, cache, concurrency=8, progress_callback=live_intraday_progress, ) print() candidates = _recent_intraday_first_candidates(all_intraday, trading_days, config.strategy) total_pairs = sum(len(v) for v in candidates.values()) print( f" Intraday-first shortlisted: {total_pairs} ticker-day pairs " f"across {len(candidates)} days" ) shortlisted_tickers = sorted({ticker for day in candidates.values() for ticker in day}) daily_bars: dict[str, list[dict]] = {} if shortlisted_tickers and _momentum_strategy_uses_daily_enrichment(config.strategy): print(f" Fetching daily enrichment bars for {len(shortlisted_tickers)} shortlisted tickers...") n_done = [0] _daily_last_pct = [-1] def daily_progress(completed: int, total: int) -> None: n_done[0] = completed pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _daily_last_pct[0] or completed == total: _daily_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() daily_fetch_start = ( date.fromisoformat(trading_days[0]) - timedelta(days=90) ).isoformat() daily_bars = await fetch_daily_bars_bulk( shortlisted_tickers, daily_fetch_start, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, prefer_intraday_fallback=True, concurrency=20, progress_callback=daily_progress, ) print(f"\n {len(daily_bars)}/{len(shortlisted_tickers)} shortlisted tickers with daily data") else: # Step 3: Phase 1 — Fetch daily bars + pre-screen print(f"[3/4] Phase 1: Fetching daily bars for {len(tickers)} tickers...") ticker_sectors = ( _load_orb_ticker_sectors(tickers) if is_orb else ( await _load_ticker_sectors_with_oracle(tickers, client) if _momentum_strategy_uses_sector_labels(config.strategy) else {} ) ) n_done = [0] _daily_last_pct = [-1] def daily_progress(completed: int, total: int) -> None: n_done[0] = completed pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _daily_last_pct[0] or completed == total: _daily_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() # For ORB mode, fetch extra prior history for enrichment (ATR/volume warmup). # enrich_daily_bars() uses only bars BEFORE each trading day, so extra bars # before trading_days[0] act as warmup and never appear in simulation results. # Without this, a short (e.g. single-day) backtest has no prior bars and all # candidates get filtered out (ATR/dollar-vol = None → zero trades). if not recent_intraday_first_scan: if is_orb or _momentum_strategy_uses_daily_enrichment(config.strategy): warmup_start = ( date.fromisoformat(trading_days[0]) - timedelta(days=90) ).isoformat() daily_fetch_start = warmup_start else: daily_fetch_start = trading_days[0] daily_bars = await fetch_daily_bars_bulk( tickers, daily_fetch_start, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, prefer_intraday_fallback=True, skip_oracle_when_unhealthy=True, concurrency=20, progress_callback=daily_progress, ) print(f"\n {len(daily_bars)}/{len(tickers)} tickers with data") momentum_enrichment: dict[str, dict[str, dict]] | None = None momentum_vix_by_day: dict[str, float] | None = None momentum_seed_candidates: dict[str, list[str]] | None = None if is_orb: # ORB: enrich daily bars, then filter by quality metrics orb_params = config.orb_strategy or ORBStrategyParams() # Ensure regime ticker is in daily_bars for market regime filter regime_ticker = getattr(orb_params, "market_regime_ticker", "SPY") or "SPY" if orb_params.market_regime_spy_threshold is not None and regime_ticker not in daily_bars: extra_bars = await fetch_daily_bars_bulk( [regime_ticker], daily_fetch_start, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, concurrency=1 ) daily_bars.update(extra_bars) print(" Computing ATR/volume enrichment...") enrichment = enrich_daily_bars(daily_bars, trading_days) candidates = orb_pre_screen_candidates( daily_bars, trading_days, enrichment, min_price=orb_params.min_price, min_atr=orb_params.min_atr_14, min_avg_dollar_vol=orb_params.min_avg_dollar_volume, max_per_day=None, ) orb_overlay_tickers_per_day: dict[str, set[str]] | None = None if ( int(getattr(orb_params, "candidate_seed_leader_overlay_slots", 0) or 0) > 0 or int(getattr(orb_params, "candidate_seed_liquid_overlay_slots", 0) or 0) > 0 ): candidates, orb_overlay_tickers_per_day = _augment_momentum_seed_candidates_with_liquid_overlay( # type: ignore[arg-type] candidates, daily_bars, trading_days, enrichment, orb_params ) if _orb_strategy_uses_catalyst(orb_params): _prior_lookback = int(getattr(orb_params, "prior_event_lookback_days", 0) or 0) if _prior_lookback > 0: all_tickers = list({t for day_tickers in candidates.values() for t in day_tickers}) print(f" Prefetching prior-event features from DB (D-{_prior_lookback}) for {len(all_tickers)} tickers...") event_features = await _prefetch_prior_event_features_db( all_tickers, trading_days, lookback_calendar_days=_prior_lookback ) print(f" Prior-event coverage: {len(event_features)} tickers with events") else: event_tickers = _orb_candidate_event_tickers(candidates, enrichment, orb_params) print(f" Fetching filing catalyst events for {len(event_tickers)} tickers...") _evt_last_pct = [-1] def event_progress(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _evt_last_pct[0] or completed == total: _evt_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() event_features = await fetch_filing_event_features_bulk( event_tickers, trading_days[0], trading_days[-1], client, cache=event_cache, concurrency=16, progress_callback=event_progress, ) print() _merge_orb_event_features(enrichment, event_features) if _orb_strategy_uses_attention(orb_params): attention_pairs = _orb_candidate_event_pairs(candidates, enrichment, orb_params) print(f" Fetching event attention for {len(attention_pairs)} ticker-days...") _attn_last_pct = [-1] def attention_progress(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _attn_last_pct[0] or completed == total: _attn_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() attention_features = await fetch_attention_features_bulk( attention_pairs, client, cache=attention_cache, concurrency=16, progress_callback=attention_progress, ) print() _merge_orb_attention_features(enrichment, attention_features) if _orb_strategy_uses_vix(orb_params): print(" Fetching VIX regime series for ORB...") momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) else: enrichment = {} if recent_intraday_first_scan: candidates = _retain_recent_intraday_shortlist( candidates, daily_bars, require_daily_features=_momentum_strategy_uses_daily_enrichment(config.strategy), ) if _momentum_strategy_requires_regime_ticker_daily(config.strategy): regime_ticker = config.strategy.market_regime_gap_ticker or "SPY" if regime_ticker not in daily_bars: extra_bars = await fetch_daily_bars_bulk( [regime_ticker], daily_fetch_start, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, prefer_intraday_fallback=True, skip_oracle_when_unhealthy=True, concurrency=1, ) daily_bars.update(extra_bars) if _momentum_strategy_uses_daily_enrichment(config.strategy): print(" Computing momentum daily enrichment...") momentum_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days) if _momentum_strategy_uses_vix(config.strategy): print(" Fetching VIX regime series...") momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) if ( not recent_intraday_first_scan and momentum_enrichment is not None and ( _momentum_strategy_uses_catalyst(config.strategy) or _momentum_strategy_uses_attention(config.strategy) ) ): preliminary_candidates = _normalize_candidate_map( _momentum_intraday_seed_candidates( daily_bars, trading_days, momentum_enrichment, config.strategy, default_threshold=config.backtest.pre_screen_threshold, use_signal_features=False, ) ) if _momentum_strategy_uses_catalyst(config.strategy): if _momentum_strategy_uses_candidate_stage_catalyst(config.strategy): event_tickers = sorted(daily_bars.keys()) else: event_tickers = _momentum_candidate_event_tickers(preliminary_candidates) print(f" Fetching momentum filing catalysts for {len(event_tickers)} tickers...") _evt_last_pct = [-1] def event_progress(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _evt_last_pct[0] or completed == total: _evt_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() event_features = await fetch_filing_event_features_bulk( event_tickers, trading_days[0], trading_days[-1], client, cache=event_cache, concurrency=16, progress_callback=event_progress, ) print() _merge_momentum_event_features(momentum_enrichment, event_features) if _momentum_strategy_uses_attention(config.strategy): attention_pairs = _momentum_candidate_event_pairs( preliminary_candidates, momentum_enrichment, config.strategy, ) print(f" Fetching momentum attention for {len(attention_pairs)} ticker-days...") _attn_last_pct = [-1] def attention_progress(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _attn_last_pct[0] or completed == total: _attn_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() attention_features = await fetch_attention_features_bulk( attention_pairs, client, cache=attention_cache, concurrency=16, progress_callback=attention_progress, ) print() _merge_momentum_attention_features(momentum_enrichment, attention_features) if not recent_intraday_first_scan: momentum_seed_candidates = _momentum_intraday_seed_candidates( daily_bars, trading_days, momentum_enrichment or {}, config.strategy, default_threshold=config.backtest.pre_screen_threshold, use_signal_features=True, ) momentum_seed_candidates, _ = _augment_momentum_seed_candidates_with_liquid_overlay( momentum_seed_candidates, daily_bars, trading_days, momentum_enrichment or {}, config.strategy, ) if _momentum_uses_historical_intraday_first(config.strategy): candidates = _normalize_candidate_map(momentum_seed_candidates) else: candidates = momentum_pre_screen_candidates( daily_bars, trading_days, momentum_enrichment or {}, threshold=config.backtest.pre_screen_threshold, max_per_day=config.strategy.candidate_final_max_per_day, strategy=config.strategy, ) total_pairs = sum(len(v) for v in candidates.values()) print(f" Pre-screened: {total_pairs} ticker-day pairs across {len(candidates)} days") if is_orb: from libs.intraday.orb_simulator import run_orb_simulation_with_state # Step 4: ORB fetch + simulate in bounded day batches. # Large windows (e.g. 90k+ ticker-days) can exceed several GB if every # 5-minute bar is materialized in one giant dict before simulation. day_chunks = _chunk_trading_days_by_pairs(trading_days, candidates) print( f"[4/4] Phase 2: Fetching intraday bars ({total_pairs} pairs) " f"in {len(day_chunks)} batches..." ) print(" Streaming ORB simulation to keep memory bounded...") orb_params = config.orb_strategy or ORBStrategyParams() day_results = [] sim_state = None all_intraday: dict[str, dict[str, list[dict]]] = {} simulated_days = 0 for chunk_idx, day_chunk in enumerate(day_chunks, start=1): chunk_candidates = { day: candidates[day] for day in day_chunk if candidates.get(day) } chunk_pairs = sum(len(v) for v in chunk_candidates.values()) print( f"\n Batch {chunk_idx}/{len(day_chunks)}: " f"{day_chunk[0]} → {day_chunk[-1]} ({chunk_pairs} pairs)" ) chunk_last_pct = [-1] def chunk_intraday_progress(completed: int, total: int, hits: int, calls: int) -> None: if completed == 0 and calls == 0 and total > 0: sys.stdout.write("\n") sys.stdout.flush() chunk_last_pct[0] = -1 pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > chunk_last_pct[0] or completed == total: chunk_last_pct[0] = pct sys.stdout.write( f"\r {_make_progress_bar(completed, total)} " f"cache:{hits} api:{calls}" ) sys.stdout.flush() chunk_intraday = await fetch_intraday_bulk( chunk_candidates, client, cache, skip_oracle_when_unhealthy=True, concurrency=8, progress_callback=chunk_intraday_progress, ) if chunk_pairs > 0: print() chunk_results, sim_state = run_orb_simulation_with_state( chunk_intraday, day_chunk, orb_params, enrichment, ticker_sectors=ticker_sectors, state=sim_state, vix_by_day=momentum_vix_by_day, overlay_tickers_per_day=orb_overlay_tickers_per_day, ) day_results.extend(chunk_results) simulated_days += len(day_chunk) print(f" Simulated {simulated_days}/{len(trading_days)} days") del chunk_intraday print(f"\n Done. Simulated {len(day_results)} days") else: # Step 4: Phase 2 — Fetch intraday bars (cache-first) if recent_intraday_first_scan: print(f"[4/4] Phase 2: Reusing recent live-scan intraday bars ({total_pairs} pairs)...") shortlisted_intraday: dict[str, dict[str, list[dict]]] = {} for day, day_candidates in candidates.items(): day_bars = all_intraday.get(day, {}) subset = { ticker: day_bars[ticker] for ticker in day_candidates if ticker in day_bars } if subset: shortlisted_intraday[day] = subset all_intraday = shortlisted_intraday print(f" Done. {len(all_intraday)} days with shortlisted intraday data") else: print(f"[4/4] Phase 2: Fetching intraday bars ({total_pairs} pairs)...") n_api = [0] _intra_last_pct = [-1] def intraday_progress(completed: int, total: int, hits: int, calls: int) -> None: n_api[0] = calls if completed == 0 and calls == 0 and total > 0: # Phase 2 reset signal: new total = miss_total, restart bar from 0 sys.stdout.write("\n") sys.stdout.flush() _intra_last_pct[0] = -1 pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _intra_last_pct[0] or completed == total: _intra_last_pct[0] = pct sys.stdout.write( f"\r {_make_progress_bar(completed, total)} " f"cache:{hits} api:{calls}" ) sys.stdout.flush() all_intraday = await fetch_intraday_bulk( candidates, client, cache, skip_oracle_when_unhealthy=True, concurrency=8, progress_callback=intraday_progress, ) print(f"\n Done. {len(all_intraday)} days with intraday data") if _momentum_uses_historical_intraday_first(config.strategy): candidates = momentum_intraday_first_candidates( all_intraday, trading_days, config.strategy, daily_enrichment=momentum_enrichment, ticker_sectors=ticker_sectors, max_per_day=config.strategy.candidate_final_max_per_day, ) total_pairs = sum(len(v) for v in candidates.values()) shortlisted_intraday: dict[str, dict[str, list[dict]]] = {} for day, day_candidates in candidates.items(): day_bars = all_intraday.get(day, {}) subset = { ticker: day_bars[ticker] for ticker in day_candidates if ticker in day_bars } if subset: shortlisted_intraday[day] = subset all_intraday = shortlisted_intraday print( " Intraday-first reranked: " f"{total_pairs} ticker-day pairs across {len(candidates)} days" ) sector_proxy_intraday: dict[str, dict[str, list[dict]]] | None = None if (not is_orb) and _momentum_strategy_uses_sector_proxies(config.strategy): print(" Fetching sector ETF proxy bars...") proxy_candidates = {day: list(SECTOR_PROXY_TICKERS) for day in trading_days} _proxy_last_pct = [-1] def proxy_progress(completed: int, total: int, hits: int, calls: int) -> None: if completed == 0 and calls == 0 and total > 0: sys.stdout.write("\n") sys.stdout.flush() _proxy_last_pct[0] = -1 pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _proxy_last_pct[0] or completed == total: _proxy_last_pct[0] = pct sys.stdout.write( f"\r {_make_progress_bar(completed, total)} " f"cache:{hits} api:{calls}" ) sys.stdout.flush() sector_proxy_intraday = await fetch_intraday_bulk( proxy_candidates, client, cache, skip_oracle_when_unhealthy=True, concurrency=4, progress_callback=proxy_progress, ) print(f"\n Done. {len(sector_proxy_intraday)} days with sector ETF proxy data") if not is_orb: # Step 5: Simulate (momentum mode still runs after full preload) print("\nSimulating trades...") _sim_last_pct = [-1] def sim_progress(done: int, total: int) -> None: pct = int(done / total * 10) * 10 if total > 0 else 0 if pct > _sim_last_pct[0] or done == total: _sim_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(done, total)}") sys.stdout.flush() day_results = run_simulation( all_intraday, trading_days, config.strategy, daily_enrichment=momentum_enrichment, vix_by_day=momentum_vix_by_day, ticker_sectors=ticker_sectors, sector_proxy_intraday_by_day=sector_proxy_intraday, ) print() # Step 6: Compute metrics run_id = str(uuid.uuid4())[:8] metrics = compute_metrics(day_results, config, run_id=run_id) return day_results, metrics, all_intraday, trading_days async def run_with_sweep(config: IntradayConfig, sweep_path: str) -> None: """Run data pipeline once, then sweep over parameter combinations.""" from apps.intraday_bt.sweep import load_sweep_config, run_sweep is_orb = config.strategy_mode == "orb" sweep = load_sweep_config(sweep_path, config) mode_label = "ORB" if is_orb else "MoMo" print(f"\nSweep [{mode_label}] {sweep.total_combinations} combos | " + " | ".join(f"{k}:{v}" for k, v in sweep.sweep_params.items())) settings = get_settings() cache = IntradayCache(config.cache.dir) if config.cache.enabled else None daily_cache = ( DailyBarCache(str(Path(config.cache.dir).with_name("daily"))) if config.cache.enabled else None ) event_cache = ( FilingEventCache(str(Path(config.cache.dir).with_name("orb_catalyst"))) if config.cache.enabled else None ) attention_cache = ( AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention"))) if config.cache.enabled else None ) async with make_intraday_oracle_client(settings) as client: print(f"\n[1/4] Resolving universe ({config.universe.source})...") tickers = await resolve_universe(config.universe, client) print(f" {len(tickers)} tickers") print("[2/4] Resolving trading calendar...") trading_days = await get_trading_days( client, config.backtest.start_date, config.backtest.end_date, config.backtest.lookback_trading_days, ) print(f" {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)") ticker_sectors = ( _load_orb_ticker_sectors(tickers) if is_orb else ( await _load_ticker_sectors_with_oracle(tickers, client) if _momentum_strategy_uses_sector_labels(config.strategy) else {} ) ) print(f"[3/4] Phase 1: Fetching daily bars for {len(tickers)} tickers...") _daily_prog_last = [-1] def daily_prog(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _daily_prog_last[0] or completed == total: _daily_prog_last[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() daily_fetch_start_sw = ( (date.fromisoformat(trading_days[0]) - timedelta(days=90)).isoformat() if (is_orb or _momentum_strategy_uses_daily_enrichment(config.strategy)) else trading_days[0] ) daily_bars = await fetch_daily_bars_bulk( tickers, daily_fetch_start_sw, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, prefer_intraday_fallback=True, skip_oracle_when_unhealthy=True, concurrency=20, progress_callback=daily_prog, ) print(f"\n {len(daily_bars)}/{len(tickers)} with data") momentum_enrichment: dict[str, dict[str, dict]] | None = None momentum_vix_by_day: dict[str, float] | None = None if is_orb: # Ensure regime ticker is in daily_bars for market regime filter (sweep mode) orb_params_sweep_check = config.orb_strategy or ORBStrategyParams() regime_ticker_sw = getattr(orb_params_sweep_check, "market_regime_ticker", "SPY") or "SPY" if orb_params_sweep_check.market_regime_spy_threshold is not None and regime_ticker_sw not in daily_bars: extra_bars_sw = await fetch_daily_bars_bulk( [regime_ticker_sw], daily_fetch_start_sw, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, concurrency=1 ) daily_bars.update(extra_bars_sw) print(" Computing ATR/volume enrichment...") enrichment = enrich_daily_bars(daily_bars, trading_days) orb_params = config.orb_strategy or ORBStrategyParams() candidates = orb_pre_screen_candidates( daily_bars, trading_days, enrichment, min_price=orb_params.min_price, min_atr=orb_params.min_atr_14, min_avg_dollar_vol=orb_params.min_avg_dollar_volume, max_per_day=None, ) orb_sweep_overlay_tickers_per_day: dict[str, set[str]] | None = None if ( int(getattr(orb_params, "candidate_seed_leader_overlay_slots", 0) or 0) > 0 or int(getattr(orb_params, "candidate_seed_liquid_overlay_slots", 0) or 0) > 0 ): candidates, orb_sweep_overlay_tickers_per_day = _augment_momentum_seed_candidates_with_liquid_overlay( # type: ignore[arg-type] candidates, daily_bars, trading_days, enrichment, orb_params ) if _orb_strategy_uses_catalyst(orb_params_sweep_check): _prior_lookback_sw = int(getattr(orb_params, "prior_event_lookback_days", 0) or 0) if _prior_lookback_sw > 0: all_tickers_sw = list({t for day_tickers in candidates.values() for t in day_tickers}) print(f" Prefetching prior-event features from DB (D-{_prior_lookback_sw}) for {len(all_tickers_sw)} tickers...") event_features = await _prefetch_prior_event_features_db( all_tickers_sw, trading_days, lookback_calendar_days=_prior_lookback_sw ) print(f" Prior-event coverage: {len(event_features)} tickers with events") else: event_tickers = _orb_candidate_event_tickers(candidates, enrichment, orb_params) _evt_prog_last = [-1] def event_prog(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _evt_prog_last[0] or completed == total: _evt_prog_last[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() event_features = await fetch_filing_event_features_bulk( event_tickers, trading_days[0], trading_days[-1], client, cache=event_cache, concurrency=16, progress_callback=event_prog, ) print() _merge_orb_event_features(enrichment, event_features) if _orb_strategy_uses_vix(orb_params_sweep_check): print(" Fetching VIX regime series for ORB sweep...") momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) else: enrichment = {} if _momentum_strategy_requires_regime_ticker_daily(config.strategy): regime_ticker_sw = config.strategy.market_regime_gap_ticker or "SPY" if regime_ticker_sw not in daily_bars: extra_bars_sw = await fetch_daily_bars_bulk( [regime_ticker_sw], daily_fetch_start_sw, trading_days[-1], client, cache=daily_cache, intraday_cache_fallback=cache, prefer_intraday_fallback=True, skip_oracle_when_unhealthy=True, concurrency=1, ) daily_bars.update(extra_bars_sw) if _momentum_strategy_uses_daily_enrichment(config.strategy): print(" Computing momentum daily enrichment...") momentum_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days) if _momentum_strategy_uses_vix(config.strategy): print(" Fetching VIX regime series...") momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days) if ( momentum_enrichment is not None and ( _momentum_strategy_uses_catalyst(config.strategy) or _momentum_strategy_uses_attention(config.strategy) ) ): preliminary_candidates = _normalize_candidate_map( _momentum_intraday_seed_candidates( daily_bars, trading_days, momentum_enrichment, config.strategy, default_threshold=config.backtest.pre_screen_threshold, use_signal_features=False, ) ) if _momentum_strategy_uses_catalyst(config.strategy): if _momentum_strategy_uses_candidate_stage_catalyst(config.strategy): event_tickers = sorted(daily_bars.keys()) else: event_tickers = _momentum_candidate_event_tickers(preliminary_candidates) print(f" Fetching momentum filing catalysts for {len(event_tickers)} tickers...") _evt_prog_last = [-1] def event_prog(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _evt_prog_last[0] or completed == total: _evt_prog_last[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() event_features = await fetch_filing_event_features_bulk( event_tickers, trading_days[0], trading_days[-1], client, cache=event_cache, concurrency=16, progress_callback=event_prog, ) print() _merge_momentum_event_features(momentum_enrichment, event_features) if _momentum_strategy_uses_attention(config.strategy): attention_pairs = _momentum_candidate_event_pairs( preliminary_candidates, momentum_enrichment, config.strategy, ) print(f" Fetching momentum attention for {len(attention_pairs)} ticker-days...") _attn_prog_last = [-1] def attention_prog(completed: int, total: int) -> None: pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _attn_prog_last[0] or completed == total: _attn_prog_last[0] = pct sys.stdout.write(f"\r {_make_progress_bar(completed, total)}") sys.stdout.flush() attention_features = await fetch_attention_features_bulk( attention_pairs, client, cache=attention_cache, concurrency=16, progress_callback=attention_prog, ) print() _merge_momentum_attention_features(momentum_enrichment, attention_features) candidates = _momentum_intraday_seed_candidates( daily_bars, trading_days, momentum_enrichment or {}, config.strategy, default_threshold=config.backtest.pre_screen_threshold, use_signal_features=True, ) candidates, _ = _augment_momentum_seed_candidates_with_liquid_overlay( candidates, daily_bars, trading_days, momentum_enrichment or {}, config.strategy, ) candidates = _normalize_candidate_map(candidates) total_pairs = sum(len(v) for v in candidates.values()) print(f" {total_pairs} candidate pairs") print(f"[4/4] Phase 2: Fetching intraday bars...") _intra_prog_last = [-1] def intra_prog(completed: int, total: int, hits: int, calls: int) -> None: if completed == 0 and calls == 0 and total > 0: sys.stdout.write("\n") sys.stdout.flush() _intra_prog_last[0] = -1 pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _intra_prog_last[0] or completed == total: _intra_prog_last[0] = pct sys.stdout.write( f"\r {_make_progress_bar(completed, total)} cache:{hits} api:{calls}" ) sys.stdout.flush() all_intraday = await fetch_intraday_bulk( candidates, client, cache, concurrency=8, progress_callback=intra_prog, ) print(f"\n Done. {len(all_intraday)} days with data") if (not is_orb) and _momentum_uses_historical_intraday_first(config.strategy): candidates = momentum_intraday_first_candidates( all_intraday, trading_days, config.strategy, daily_enrichment=momentum_enrichment, ticker_sectors=ticker_sectors, max_per_day=config.strategy.candidate_final_max_per_day, ) total_pairs = sum(len(v) for v in candidates.values()) shortlisted_intraday: dict[str, dict[str, list[dict]]] = {} for day, day_candidates in candidates.items(): day_bars = all_intraday.get(day, {}) subset = { ticker: day_bars[ticker] for ticker in day_candidates if ticker in day_bars } if subset: shortlisted_intraday[day] = subset all_intraday = shortlisted_intraday print( " Intraday-first reranked: " f"{total_pairs} ticker-day pairs across {len(candidates)} days" ) sector_proxy_intraday: dict[str, dict[str, list[dict]]] | None = None if (not is_orb) and _momentum_strategy_uses_sector_proxies(config.strategy): print(" Fetching sector ETF proxy bars for sweep...") proxy_candidates = {day: list(SECTOR_PROXY_TICKERS) for day in trading_days} _proxy_prog_last = [-1] def proxy_prog(completed: int, total: int, hits: int, calls: int) -> None: if completed == 0 and calls == 0 and total > 0: sys.stdout.write("\n") sys.stdout.flush() _proxy_prog_last[0] = -1 pct = int(completed / total * 10) * 10 if total > 0 else 0 if pct > _proxy_prog_last[0] or completed == total: _proxy_prog_last[0] = pct sys.stdout.write( f"\r {_make_progress_bar(completed, total)} cache:{hits} api:{calls}" ) sys.stdout.flush() sector_proxy_intraday = await fetch_intraday_bulk( proxy_candidates, client, cache, concurrency=4, progress_callback=proxy_prog, ) print(f"\n Done. {len(sector_proxy_intraday)} days with sector ETF proxy data") print(f"\nRunning {sweep.total_combinations} sweep combinations...") completed_sw = [0] _sweep_last_pct = [-1] def sweep_prog(done: int, total: int) -> None: completed_sw[0] = done pct = int(done / total * 10) * 10 if total > 0 else 0 if pct > _sweep_last_pct[0] or done == total: _sweep_last_pct[0] = pct sys.stdout.write(f"\r {_make_progress_bar(done, total)}") sys.stdout.flush() sweep_results = run_sweep( sweep, all_intraday, trading_days, progress_callback=sweep_prog, enrichment=enrichment, momentum_enrichment=momentum_enrichment, vix_by_day=momentum_vix_by_day, ticker_sectors=ticker_sectors if not is_orb else None, sector_proxy_intraday_by_day=sector_proxy_intraday, ) print() # Display top results print(format_sweep_comparison(sweep_results, top_n=20)) # Also show full details for the #1 configuration if sweep_results: from apps.intraday_bt.sweep import apply_overrides best = sweep_results[0] best_config = apply_overrides(config, best.params) if is_orb: from libs.intraday.orb_simulator import run_orb_simulation best_day_results = run_orb_simulation( all_intraday, trading_days, best_config.orb_strategy, enrichment, vix_by_day=momentum_vix_by_day, ) else: best_day_results = run_simulation( all_intraday, trading_days, best_config.strategy, daily_enrichment=momentum_enrichment, vix_by_day=momentum_vix_by_day, ticker_sectors=ticker_sectors, sector_proxy_intraday_by_day=sector_proxy_intraday, ) print("\n=== Best Configuration Detail ===") print(format_summary(best.metrics, best_config)) print(format_top_trades(best_day_results, n=5)) # Save sweep results out = Path(config.output.dir) out.mkdir(parents=True, exist_ok=True) from datetime import datetime import json ts = datetime.now().strftime("%Y%m%d_%H%M%S") sweep_file = out / f"sweep_{ts}.json" sweep_data = [ {"params": sr.params, "metrics": sr.metrics.model_dump()} for sr in sweep_results ] sweep_file.write_text(json.dumps(sweep_data, indent=2, default=str)) print(f"\nSweep results saved to: {sweep_file}") # ── CLI ──────────────────────────────────────────────────────────────────── def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Morning Momentum Intraday Backtester", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--config", help="Strategy config YAML file path") parser.add_argument("--strategy", choices=["momentum", "orb"], default=None, help="Strategy mode: 'momentum' (default) or 'orb'") parser.add_argument("--sweep", help="Parameter sweep YAML file path") parser.add_argument("--days", type=int, help="Number of trading days to backtest") parser.add_argument("--start", help="Start date YYYY-MM-DD (overrides --days)") parser.add_argument("--end", help="End date YYYY-MM-DD (default: today)") parser.add_argument("--universe", choices=["sp500", "nasdaq100", "broad", "midlarge", "largecap", "midcap", "smallmid", "screener"], help="Universe source") parser.add_argument("--top-n", type=int, dest="top_n", help="Number of stocks to buy per day") parser.add_argument("--stop-loss", dest="stop_loss", help="Stop loss % (e.g. -0.02) or 'none' to disable") parser.add_argument("--entry-min", type=int, dest="entry_min", help="Minutes after open to enter (default 30)") parser.add_argument("--exit-min", type=int, dest="exit_min", help="Minutes before close to exit (default 30)") parser.add_argument("--min-gain", type=float, dest="min_gain", help="Minimum morning gain to qualify (default 0.01 = 1%%)") parser.add_argument("--no-cache", action="store_true", dest="no_cache", help="Disable cache use for this run") parser.add_argument("--refresh-cache", action="store_true", dest="refresh_cache", help="Force re-download all intraday data") parser.add_argument("--verbose", action="store_true", help="Show detailed per-day output") parser.add_argument("--output-dir", dest="output_dir", help="Override output directory") parser.add_argument("--initial-capital", type=float, dest="initial_capital", default=None, help="Override initial capital (e.g. 50000)") parser.add_argument("--compound-returns", dest="compound_returns", action="store_true", default=None, help="Override: use compound returns (복리) regardless of config") parser.add_argument("--no-compound-returns", dest="compound_returns", action="store_false", help="Override: use simple returns (단리) regardless of config") parser.add_argument("--daily-budget-reset", dest="daily_budget_reset", action="store_true", default=None, help="Research mode: reset sizing_capital to initial_capital every day " "(ignores prior PnL; takes precedence over compound_returns)") parser.add_argument("--no-daily-budget-reset", dest="daily_budget_reset", action="store_false", help="Disable daily budget reset mode") parser.add_argument("--cache-stats", action="store_true", dest="cache_stats", help="Show cache statistics and exit") return parser.parse_args() async def main_async() -> None: args = parse_args() # Cache stats shortcut if args.cache_stats: config = load_config(args.config) cache = IntradayCache(config.cache.dir) stats = cache.stats() print(f"Cache directory: {config.cache.dir}") print(f" Files: {stats['total_files']}") print(f" Size: {stats['total_mb']} MB") print(f" Tickers: {stats['tickers']}") print(f" Date range: {stats['date_min']} → {stats['date_max']}") return # Load + apply config config = load_config(args.config) config = apply_cli_overrides(config, args) # Sweep mode if args.sweep: await run_with_sweep(config, args.sweep) return # Single run mode day_results, metrics, all_intraday, trading_days = await run( config, refresh_cache=args.refresh_cache ) # Display results print(format_summary(metrics, config)) if config.output.verbose or args.verbose: print(format_daily_breakdown(day_results)) print(format_top_trades(day_results, n=5)) # Always show daily breakdown (compact version) if not (config.output.verbose or args.verbose): print(format_daily_breakdown(day_results)) # Save results if day_results: out_file = write_results(metrics, day_results, config, config.output.dir) print(f"\nResults saved to: {out_file}") def main() -> None: asyncio.run(main_async()) if __name__ == "__main__": main()