You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2093 lines
87 KiB
Python

"""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 (
_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
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),
]
) or params.use_five_sleeves
def _momentum_strategy_uses_catalyst(params: StrategyParams) -> bool:
return (
params.candidate_require_event_flag
or params.candidate_min_event_score is not None
or params.candidate_weight_event_score > 0
or params.candidate_intraday_weight_event_score > 0
)
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,
) -> dict[str, list[str]]:
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)
if slots <= 0 and leader_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]] = {}
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)
for day in trading_days:
day_candidates = list(candidates.get(day, []))
chosen = set(day_candidates)
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)
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)
if day_candidates:
result[day] = day_candidates
return result
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 config.strategy.max_positions_per_sector
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,
)
if _orb_strategy_uses_catalyst(orb_params):
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_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 = _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):
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 = {
day: list(day_tickers)
for day, day_tickers in momentum_seed_candidates.items()
if day_tickers
}
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,
)
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,
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"
)
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,
)
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 config.strategy.max_positions_per_sector
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,
)
if _orb_strategy_uses_catalyst(orb_params_sweep_check):
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_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 = _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):
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,
)
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,
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"
)
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,
)
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,
)
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", "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()