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.
2556 lines
108 KiB
Python
2556 lines
108 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 (
|
|
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()
|