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.

4121 lines
179 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 math
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,
LayeredDailyBarCache,
ReadOnlyDailyBarCache,
)
from libs.intraday.catalyst import (
AttentionEventCache,
FilingEventCache,
PriorEventFeatureSnapshotCache,
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_yaml(path: Path) -> dict:
"""Load a standalone YAML config.
`extends` inheritance is intentionally unsupported. Strategy YAML files must
be materialized in full so a single file is always the source of truth.
"""
resolved = path.resolve()
with open(resolved) as f:
raw = yaml.safe_load(f) or {}
if "extends" in raw:
raise ValueError(
f"Config inheritance via 'extends' is no longer supported: {resolved}. "
"Use a fully materialized standalone YAML file."
)
return raw
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()
raw = _load_config_yaml(Path(path))
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" is a sizing mode, so make the CLI flag disable reset unless
# the user explicitly asks for reset in the same command.
compound_override = getattr(args, "compound_returns", None)
capital_override = getattr(args, "initial_capital", None)
reset_override = getattr(args, "daily_budget_reset", None)
if compound_override is True and reset_override is None:
reset_override = False
elif reset_override is True and compound_override is None:
compound_override = False
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)
# Keep the generic strategy block in saved result metadata aligned with
# the ORB block. The simulator uses orb_strategy, but humans and UI
# diagnostics often inspect config.strategy first.
strategy_dict["compound_returns"] = orb_strategy.compound_returns
strategy_dict["daily_budget_reset"] = orb_strategy.daily_budget_reset
strategy_dict["initial_capital"] = orb_strategy.initial_capital
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_ownership_13dg(params: ORBStrategyParams) -> bool:
return (
int(getattr(params, "ownership_13dg_lookback_days", 0) or 0) > 0
and (
float(getattr(params, "weight_ownership_13dg", 0.0) or 0.0) > 0.0
or float(getattr(params, "weight_ownership_initial_13dg", 0.0) or 0.0) > 0.0
or float(getattr(params, "ownership_initial_size_scale", 1.0) or 1.0) > 1.0
or _orb_strategy_uses_ownership_seed_overlay(params)
or _orb_strategy_uses_idle_sleeve(params)
)
)
def _orb_strategy_uses_ownership_seed_overlay(params: ORBStrategyParams) -> bool:
return (
int(getattr(params, "candidate_seed_ownership_overlay_slots", 0) or 0) > 0
and int(getattr(params, "ownership_13dg_lookback_days", 0) or 0) > 0
)
def _orb_strategy_uses_form4_seed_overlay(params: ORBStrategyParams) -> bool:
return (
int(getattr(params, "candidate_seed_form4_overlay_slots", 0) or 0) > 0
and int(getattr(params, "form4_lookback_days", 0) or 0) > 0
)
def _orb_strategy_uses_form4(params: ORBStrategyParams) -> bool:
return (
int(getattr(params, "form4_lookback_days", 0) or 0) > 0
and (
float(getattr(params, "form4_size_scale", 1.0) or 1.0) > 1.0
or _orb_strategy_uses_form4_seed_overlay(params)
or _orb_strategy_uses_idle_sleeve(params)
)
)
def _orb_strategy_uses_vix(params: ORBStrategyParams) -> bool:
return any(
value is not None and value != default
for value, default in [
(params.max_vix, None),
(params.vix_size_scale_low, None),
(params.vix_size_scale_high, None),
]
) or params.vix_size_scale_min != 1.0
def _orb_strategy_requires_regime_ticker_daily(params: ORBStrategyParams) -> bool:
"""Whether ORB simulation needs daily bars for the configured regime ticker."""
return any(
value is not None and value != default
for value, default in [
(params.market_regime_spy_threshold, None),
(params.regime_size_scale_low, None),
(params.regime_size_scale_high, None),
(params.regime_skip_below, None),
]
)
def _build_intraday_data_provenance(config: IntradayConfig) -> dict[str, object]:
provenance: dict[str, object] = {}
if config.strategy_mode != "orb":
return provenance
orb_params = config.orb_strategy or ORBStrategyParams()
daily_snapshot_id = getattr(orb_params, "daily_bar_snapshot_id", None)
if daily_snapshot_id:
provenance["daily_bars"] = {
"enabled": True,
"snapshot_id": str(daily_snapshot_id),
"overlay_enabled": bool(
getattr(orb_params, "daily_bar_snapshot_overlay_enabled", False)
),
}
if (
int(getattr(orb_params, "prior_event_lookback_days", 0) or 0) > 0
and float(getattr(orb_params, "weight_event_catalyst", 0.0) or 0.0) > 0.0
):
cache = PriorEventFeatureSnapshotCache(
snapshot_id=getattr(orb_params, "prior_event_snapshot_id", None),
)
provenance["prior_event"] = {
"enabled": True,
"snapshot_id": cache.snapshot_id,
"lookback_calendar_days": int(orb_params.prior_event_lookback_days),
"event_types": list(getattr(orb_params, "prior_event_types", None) or []),
}
if getattr(orb_params, "prior_event_decay_half_life_days", None) is not None:
provenance["prior_event"]["decay_half_life_days"] = float(
orb_params.prior_event_decay_half_life_days
)
provenance["prior_event"]["decay_min_score"] = getattr(
orb_params, "prior_event_decay_min_score", None
)
provenance["prior_event"]["guidance_score_scale"] = float(
getattr(orb_params, "prior_event_guidance_score_scale", 1.0) or 1.0
)
if _orb_strategy_uses_ownership_13dg(orb_params):
provenance["ownership_13dg"] = {
"reference_path": getattr(orb_params, "ownership_13dg_reference_path", None),
"lookback_calendar_days": int(
getattr(orb_params, "ownership_13dg_lookback_days", 0) or 0
),
"pit_rule": "filing_date < trade_date",
"candidate_seed_overlay_slots": int(
getattr(orb_params, "candidate_seed_ownership_overlay_slots", 0) or 0
),
}
if _orb_strategy_uses_form4(orb_params):
provenance["form4"] = {
"reference_path": getattr(orb_params, "form4_reference_path", None),
"lookback_calendar_days": int(
getattr(orb_params, "form4_lookback_days", 0) or 0
),
"pit_rule": "filing_date < trade_date",
}
return provenance
def _resolve_daily_cache_dir(config: IntradayConfig) -> str:
default_dir = str(Path(config.cache.dir).with_name("daily"))
if config.strategy_mode != "orb" or config.orb_strategy is None:
return default_dir
snapshot_id = getattr(config.orb_strategy, "daily_bar_snapshot_id", None)
if not snapshot_id:
return default_dir
return str(Path(config.cache.dir).with_name("daily_snapshots") / str(snapshot_id))
def _build_daily_cache(
config: IntradayConfig,
) -> DailyBarCache | LayeredDailyBarCache | ReadOnlyDailyBarCache:
snapshot_dir = _resolve_daily_cache_dir(config)
if config.strategy_mode != "orb" or config.orb_strategy is None:
return DailyBarCache(snapshot_dir)
snapshot_id = getattr(config.orb_strategy, "daily_bar_snapshot_id", None)
if not snapshot_id:
return DailyBarCache(snapshot_dir)
if not bool(getattr(config.orb_strategy, "daily_bar_snapshot_overlay_enabled", False)):
return ReadOnlyDailyBarCache(DailyBarCache(snapshot_dir))
overlay_dir = Path(config.cache.dir).with_name("daily_overlays") / str(snapshot_id)
return LayeredDailyBarCache(
DailyBarCache(snapshot_dir),
DailyBarCache(str(overlay_dir)),
)
def _orb_prior_event_decay_enabled(params: ORBStrategyParams) -> bool:
return getattr(params, "prior_event_decay_half_life_days", None) is not None
def _prior_event_features_have_detail(features: dict[str, dict[str, dict]]) -> bool:
"""Return True when a prior-event feature map has enough detail for decay."""
for day_map in features.values():
for payload in day_map.values():
if not payload.get("event_flag"):
continue
if payload.get("prior_event_candidates"):
return True
return False
return True
def _prior_event_type_score(event_type: str, params: ORBStrategyParams) -> float:
event_type_l = str(event_type or "").lower()
if "guidance" in event_type_l:
return max(
0.0,
float(getattr(params, "prior_event_guidance_score_scale", 1.0) or 1.0),
)
if "earnings" in event_type_l or "results" in event_type_l:
return 1.0
return 0.75
def _apply_orb_prior_event_decay_features(
event_features: dict[str, dict[str, dict]],
params: ORBStrategyParams,
) -> dict[str, dict[str, dict]]:
"""Apply PIT-safe recency/type decay to prior-event features.
D-1 keeps full event strength; older events decay by configured half-life.
The raw prior-event candidates remain attached for diagnostics and cache reuse.
"""
half_life = getattr(params, "prior_event_decay_half_life_days", None)
if half_life is None:
return event_features
half_life = float(half_life)
if half_life <= 0:
return event_features
min_score = getattr(params, "prior_event_decay_min_score", None)
min_score_f = float(min_score) if min_score is not None else None
for day_map in event_features.values():
for payload in day_map.values():
if not payload.get("event_flag"):
continue
candidates = payload.get("prior_event_candidates") or []
best_score = 0.0
best_candidate: dict | None = None
for candidate in candidates:
try:
days_ago = int(candidate.get("days_ago"))
except (TypeError, ValueError):
continue
if days_ago < 1:
continue
type_score = _prior_event_type_score(
str(candidate.get("event_type") or ""),
params,
)
age_after_first_day = max(days_ago - 1, 0)
decay = math.pow(0.5, age_after_first_day / half_life)
score = type_score * decay
if score > best_score:
best_score = score
best_candidate = candidate
if best_candidate is None:
continue
payload["prior_event_binary_score"] = float(payload.get("event_score") or 0.0)
payload["prior_event_decay_score"] = round(best_score, 6)
payload["prior_event_best_days_ago"] = int(best_candidate["days_ago"])
payload["prior_event_best_event_type"] = str(best_candidate.get("event_type") or "")
if min_score_f is not None and best_score < min_score_f:
payload["event_flag"] = False
payload["event_score"] = 0.0
else:
payload["event_score"] = round(best_score, 6)
return event_features
def _coerce_ownership_date(value: object) -> date | None:
if value is None:
return None
if isinstance(value, date):
return value
text = str(value).strip()
if not text:
return None
try:
return date.fromisoformat(text[:10])
except ValueError:
return None
def _load_orb_ownership_13dg_features(
tickers: list[str],
trading_days: list[str],
params: ORBStrategyParams,
) -> dict[str, dict[str, dict]]:
"""Load PIT-safe 13D/13G ownership flags from local parquet.
The parquet is local reference data, so this path is deterministic and does
not depend on the live Oracle API. Filing dates are strictly before the
trade date to avoid same-day filing lookahead.
"""
lookback_days = int(getattr(params, "ownership_13dg_lookback_days", 0) or 0)
if lookback_days <= 0 or not tickers or not trading_days:
return {}
path = Path(
getattr(
params,
"ownership_13dg_reference_path",
"data/reference/ownership_13d13g_events_pit.parquet",
)
or "data/reference/ownership_13d13g_events_pit.parquet"
)
if not path.exists():
return {}
import pyarrow.parquet as pq
ticker_set = {str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}
if not ticker_set:
return {}
rows = pq.read_table(str(path)).to_pylist()
events_by_ticker: dict[str, list[dict[str, object]]] = {}
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
if symbol not in ticker_set:
continue
filing_date = _coerce_ownership_date(row.get("filing_date"))
if filing_date is None:
continue
events_by_ticker.setdefault(symbol, []).append(
{
"filing_date": filing_date,
"form_type": str(row.get("form_type") or "").strip().upper(),
"is_initial_for_owner": bool(row.get("is_initial_for_owner") or False),
"is_13g_to_13d_transition": bool(
row.get("is_13g_to_13d_transition") or False
),
"activist_flag": bool(row.get("activist_flag") or False),
"ownership_strength_score": row.get("ownership_strength_score"),
}
)
trading_dates = [(day, date.fromisoformat(day)) for day in trading_days]
result: dict[str, dict[str, dict]] = {}
for ticker, ticker_events in events_by_ticker.items():
ticker_events.sort(key=lambda item: item["filing_date"])
for day, trade_date in trading_dates:
cutoff = trade_date - timedelta(days=lookback_days)
eligible = [
event
for event in ticker_events
if cutoff <= event["filing_date"] < trade_date
]
if not eligible:
continue
days_since = min(
(trade_date - event["filing_date"]).days for event in eligible
)
strength_scores: list[float] = []
for event in eligible:
try:
strength_scores.append(float(event.get("ownership_strength_score") or 0.0))
except (TypeError, ValueError):
continue
max_strength = max(strength_scores) if strength_scores else 0.0
has_initial = any(bool(event.get("is_initial_for_owner")) for event in eligible)
has_active_13d = any(
str(event.get("form_type") or "") in {"SC 13D", "SCHEDULE 13D"}
for event in eligible
)
result.setdefault(ticker, {})[day] = {
"ownership_13dg_flag": True,
"ownership_13dg_initial_flag": has_initial,
"ownership_13dg_active_13d_flag": has_active_13d,
"ownership_13dg_transition_flag": any(
bool(event.get("is_13g_to_13d_transition")) for event in eligible
),
"ownership_13dg_activist_flag": any(
bool(event.get("activist_flag")) for event in eligible
),
"ownership_13dg_event_count": len(eligible),
"ownership_13dg_days_since": days_since,
"ownership_13dg_strength_score": max_strength,
"ownership_13dg_score": 1.0,
"ownership_13dg_initial_score": 1.0 if has_initial else 0.0,
}
return result
def _merge_orb_ownership_13dg_features(
enrichment: dict[str, dict[str, dict]],
ownership_features: dict[str, dict[str, dict]],
) -> None:
for ticker, day_map in ownership_features.items():
ticker_enrichment = enrichment.setdefault(ticker, {})
for day, payload in day_map.items():
ticker_enrichment.setdefault(day, {}).update(payload)
def _load_orb_form4_features(
tickers: list[str],
trading_days: list[str],
params: ORBStrategyParams,
) -> dict[str, dict[str, dict]]:
"""Load PIT-safe Form 4 purchase-cluster features from local parquet."""
lookback_days = int(getattr(params, "form4_lookback_days", 0) or 0)
if lookback_days <= 0 or not tickers or not trading_days:
return {}
path = Path(
getattr(
params,
"form4_reference_path",
"data/reference/form4_daily_events_pit.parquet",
)
or "data/reference/form4_daily_events_pit.parquet"
)
if not path.exists():
return {}
import pyarrow.parquet as pq
ticker_set = {str(ticker).strip().upper() for ticker in tickers if str(ticker).strip()}
if not ticker_set:
return {}
rows = pq.read_table(str(path)).to_pylist()
events_by_ticker: dict[str, list[dict[str, object]]] = {}
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
if symbol not in ticker_set:
continue
filing_date = _coerce_ownership_date(row.get("filing_date"))
if filing_date is None:
continue
events_by_ticker.setdefault(symbol, []).append(
{
"filing_date": filing_date,
"total_value": row.get("total_value"),
"owner_count": row.get("owner_count"),
"transaction_count": row.get("transaction_count"),
"c_suite_count": row.get("c_suite_count"),
"role_weight_score": row.get("role_weight_score"),
"has_officer_or_director": row.get("has_officer_or_director"),
}
)
trading_dates = [(day, date.fromisoformat(day)) for day in trading_days]
result: dict[str, dict[str, dict]] = {}
for ticker, ticker_events in events_by_ticker.items():
ticker_events.sort(key=lambda item: item["filing_date"])
for day, trade_date in trading_dates:
cutoff = trade_date - timedelta(days=lookback_days)
eligible = [
event
for event in ticker_events
if cutoff <= event["filing_date"] < trade_date
]
if not eligible:
continue
def _num(event: dict[str, object], key: str) -> float:
try:
return float(event.get(key) or 0.0)
except (TypeError, ValueError):
return 0.0
def _int_num(event: dict[str, object], key: str) -> int:
try:
return int(event.get(key) or 0)
except (TypeError, ValueError):
return 0
days_since = min(
(trade_date - event["filing_date"]).days for event in eligible
)
result.setdefault(ticker, {})[day] = {
"form4_flag": True,
"form4_days_since": days_since,
"form4_event_count": len(eligible),
"form4_total_value": sum(_num(event, "total_value") for event in eligible),
"form4_owner_count": max(_int_num(event, "owner_count") for event in eligible),
"form4_transaction_count": max(
_int_num(event, "transaction_count") for event in eligible
),
"form4_c_suite_count": max(
_int_num(event, "c_suite_count") for event in eligible
),
"form4_role_weight_score": max(
_num(event, "role_weight_score") for event in eligible
),
"form4_has_officer_or_director": any(
bool(event.get("has_officer_or_director")) for event in eligible
),
}
return result
def _merge_orb_form4_features(
enrichment: dict[str, dict[str, dict]],
form4_features: dict[str, dict[str, dict]],
) -> None:
for ticker, day_map in form4_features.items():
ticker_enrichment = enrichment.setdefault(ticker, {})
for day, payload in day_map.items():
ticker_enrichment.setdefault(day, {}).update(payload)
async def _fetch_prior_event_features_db_live(
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,
event_type,
event_direction,
filed_at_utc::text AS filed_at_utc
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 events per ticker. Event timestamps are kept for diagnostics, but
# prior-event scoring only admits events from earlier calendar days.
from collections import defaultdict
events_by_ticker: dict[str, list[dict]] = defaultdict(list)
for row in rows:
ticker = ticker_from_sid.get(row["symbol_id"])
if ticker:
events_by_ticker[ticker].append({
"event_date": row["event_date"],
"event_type": row["event_type"],
"event_direction": row["event_direction"],
"filed_at_utc": row["filed_at_utc"],
})
# 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:
ticker_events = events_by_ticker.get(ticker, [])
if not ticker_events:
continue
ticker_map: dict[str, dict] = {}
for td_str in trading_days:
td = datetime.strptime(td_str, "%Y-%m-%d").date()
eligible_events: list[dict] = []
for event in ticker_events:
ev = datetime.strptime(str(event.get("event_date")), "%Y-%m-%d").date()
days_ago = (td - ev).days
if 1 <= days_ago <= lookback_calendar_days:
eligible_events.append({
"event_date": str(event.get("event_date")),
"event_type": str(event.get("event_type") or ""),
"event_direction": event.get("event_direction"),
"filed_at_utc": event.get("filed_at_utc"),
"days_ago": days_ago,
})
if eligible_events:
event_types_out = sorted({
str(event.get("event_type") or "")
for event in eligible_events
if str(event.get("event_type") or "")
})
ticker_map[td_str] = {
"event_flag": True,
"event_score": 1.0,
"event_count": len(eligible_events),
"event_types": event_types_out,
"prior_event_days_ago": min(int(e["days_ago"]) for e in eligible_events),
"prior_event_candidates": sorted(
eligible_events,
key=lambda e: (int(e["days_ago"]), str(e.get("event_type") or "")),
),
}
if ticker_map:
result[ticker] = ticker_map
return result
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"),
snapshot_cache: PriorEventFeatureSnapshotCache | None = None,
require_event_details: bool = False,
) -> dict[str, dict[str, dict]]:
"""Return prior-event enrichment, freezing each request in a local snapshot."""
if not tickers or not trading_days:
return {}
normalized_types = tuple(
sorted({
str(event_type).strip()
for event_type in event_types
if str(event_type).strip()
})
)
if snapshot_cache is not None:
cached = await asyncio.to_thread(
snapshot_cache.get,
tickers,
trading_days,
lookback_calendar_days,
normalized_types,
)
if cached is not None and (
not require_event_details or _prior_event_features_have_detail(cached)
):
return cached
result = await _fetch_prior_event_features_db_live(
tickers,
trading_days,
lookback_calendar_days=lookback_calendar_days,
event_types=normalized_types,
)
if snapshot_cache is not None:
await asyncio.to_thread(
snapshot_cache.put,
tickers,
trading_days,
lookback_calendar_days,
normalized_types,
result,
)
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)
ownership_slots = int(getattr(strategy, "candidate_seed_ownership_overlay_slots", 0) or 0)
form4_slots = int(getattr(strategy, "candidate_seed_form4_overlay_slots", 0) or 0)
if (
slots <= 0
and leader_slots <= 0
and moderate_slots <= 0
and event_slots <= 0
and ownership_slots <= 0
and form4_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)
ownership_initial_only = bool(getattr(strategy, "candidate_seed_ownership_initial_only", True))
ownership_min_strength = getattr(strategy, "candidate_seed_ownership_min_strength_score", None)
ownership_min_gap = getattr(strategy, "candidate_seed_ownership_min_gap_pct", None)
ownership_max_gap = getattr(strategy, "candidate_seed_ownership_max_gap_pct", None)
ownership_min_avg_dollar_vol = getattr(
strategy,
"candidate_seed_ownership_min_avg_dollar_vol_30d",
None,
)
ownership_min_ret_5d = getattr(strategy, "candidate_seed_ownership_min_ret_5d", None)
ownership_max_entropy = getattr(strategy, "candidate_seed_ownership_max_entropy_20d", None)
form4_min_total_value = getattr(strategy, "candidate_seed_form4_min_total_value", None)
form4_min_owner_count = getattr(strategy, "candidate_seed_form4_min_owner_count", None)
form4_min_c_suite_count = getattr(strategy, "candidate_seed_form4_min_c_suite_count", None)
form4_require_cluster_or_csuite = bool(
getattr(strategy, "candidate_seed_form4_require_cluster_or_csuite", False)
)
form4_min_gap = getattr(strategy, "candidate_seed_form4_min_gap_pct", None)
form4_max_gap = getattr(strategy, "candidate_seed_form4_max_gap_pct", None)
form4_min_avg_dollar_vol = getattr(
strategy,
"candidate_seed_form4_min_avg_dollar_vol_30d",
None,
)
form4_min_ret_5d = getattr(strategy, "candidate_seed_form4_min_ret_5d", None)
form4_max_entropy = getattr(strategy, "candidate_seed_form4_max_entropy_20d", None)
def overlay_gap_pct(info: dict, bar: dict) -> float | None:
gap_pct = info.get("gap_pct")
if gap_pct is not None:
return float(gap_pct)
prev_close = info.get("prev_close")
open_price = bar.get("open")
if prev_close in (None, 0) or open_price in (None, 0):
return None
return (float(open_price) - float(prev_close)) / float(prev_close)
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():
bar = date_map.get(day)
if not bar:
continue
info = enrichment.get(ticker, {}).get(day, {})
gap_pct = overlay_gap_pct(info, bar)
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():
bar = date_map.get(day)
if not bar:
continue
info = enrichment.get(ticker, {}).get(day, {})
gap_pct = overlay_gap_pct(info, bar)
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():
bar = date_map.get(day)
if not bar:
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 = overlay_gap_pct(info, bar)
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)
ownership_ranked: list[tuple[float, float, float, int, float, str]] = []
for ticker, date_map in ticker_day_bar.items():
bar = date_map.get(day)
if not bar:
continue
info = enrichment.get(ticker, {}).get(day, {})
if not bool(info.get("ownership_13dg_flag")):
continue
if ownership_initial_only and not bool(info.get("ownership_13dg_initial_flag")):
continue
strength = float(info.get("ownership_13dg_strength_score") or 0.0)
if ownership_min_strength is not None and strength < float(ownership_min_strength):
continue
gap_pct = overlay_gap_pct(info, bar)
if ownership_min_gap is not None and (gap_pct is None or gap_pct < ownership_min_gap):
continue
if ownership_max_gap is not None and (gap_pct is None or gap_pct > ownership_max_gap):
continue
avg_dollar_vol = info.get("avg_dollar_vol_30d")
if ownership_min_avg_dollar_vol is not None and (
avg_dollar_vol is None or avg_dollar_vol < ownership_min_avg_dollar_vol
):
continue
ret_5d = info.get("ret_5d")
if ownership_min_ret_5d is not None and (ret_5d is None or ret_5d < ownership_min_ret_5d):
continue
entropy_20d = info.get("entropy_20d")
if ownership_max_entropy is not None and (
entropy_20d is None or entropy_20d > ownership_max_entropy
):
continue
days_since = int(info.get("ownership_13dg_days_since") or 9999)
ownership_ranked.append(
(
strength,
float(avg_dollar_vol or 0.0),
float(ret_5d or 0.0),
-days_since,
-float(entropy_20d if entropy_20d is not None else 1.0),
ticker,
)
)
if ownership_ranked:
ranked_ownership = [
ticker
for *_rest, ticker in sorted(ownership_ranked, reverse=True)[:ownership_slots]
]
for ticker in ranked_ownership:
if ticker not in chosen:
day_candidates.append(ticker)
chosen.add(ticker)
day_overlay_tickers.add(ticker)
enrichment.setdefault(ticker, {}).setdefault(day, {})[
"candidate_seed_ownership_overlay"
] = True
form4_ranked: list[tuple[float, int, int, float, float, str]] = []
for ticker, date_map in ticker_day_bar.items():
bar = date_map.get(day)
if not bar:
continue
info = enrichment.get(ticker, {}).get(day, {})
if not bool(info.get("form4_flag")):
continue
total_value = float(info.get("form4_total_value") or 0.0)
if form4_min_total_value is not None and total_value < float(form4_min_total_value):
continue
owner_count = int(info.get("form4_owner_count") or 0)
c_suite_count = int(info.get("form4_c_suite_count") or 0)
if form4_require_cluster_or_csuite:
cluster_ok = (
form4_min_owner_count is not None
and owner_count >= int(form4_min_owner_count)
)
csuite_ok = (
form4_min_c_suite_count is not None
and c_suite_count >= int(form4_min_c_suite_count)
)
if not (cluster_ok or csuite_ok):
continue
else:
if form4_min_owner_count is not None and owner_count < int(form4_min_owner_count):
continue
if (
form4_min_c_suite_count is not None
and c_suite_count < int(form4_min_c_suite_count)
):
continue
gap_pct = overlay_gap_pct(info, bar)
if form4_min_gap is not None and (gap_pct is None or gap_pct < form4_min_gap):
continue
if form4_max_gap is not None and (gap_pct is None or gap_pct > form4_max_gap):
continue
avg_dollar_vol = info.get("avg_dollar_vol_30d")
if form4_min_avg_dollar_vol is not None and (
avg_dollar_vol is None or avg_dollar_vol < form4_min_avg_dollar_vol
):
continue
ret_5d = info.get("ret_5d")
if form4_min_ret_5d is not None and (ret_5d is None or ret_5d < form4_min_ret_5d):
continue
entropy_20d = info.get("entropy_20d")
if form4_max_entropy is not None and (
entropy_20d is None or entropy_20d > form4_max_entropy
):
continue
days_since = int(info.get("form4_days_since") or 9999)
role_weight = float(info.get("form4_role_weight_score") or 0.0)
form4_ranked.append(
(
role_weight,
owner_count,
c_suite_count,
total_value,
-days_since,
ticker,
)
)
if form4_ranked:
ranked_form4 = [
ticker
for *_rest, ticker in sorted(form4_ranked, reverse=True)[:form4_slots]
]
for ticker in ranked_form4:
if ticker not in chosen:
day_candidates.append(ticker)
chosen.add(ticker)
day_overlay_tickers.add(ticker)
enrichment.setdefault(ticker, {}).setdefault(day, {})[
"candidate_seed_form4_overlay"
] = True
leader_ranked: list[tuple[float, float, float, float, str]] = []
for ticker, date_map in ticker_day_bar.items():
bar = date_map.get(day)
if not bar:
continue
info = enrichment.get(ticker, {}).get(day, {})
gap_pct = overlay_gap_pct(info, bar)
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 _orb_intraday_support_tickers(params: ORBStrategyParams) -> list[str]:
"""Extra intraday-only tickers needed by the ORB simulator beyond trade candidates."""
support: set[str] = set()
uses_market_orb_quality = any(
getattr(params, key, None) is not None
for key in (
"market_orb_quality_size_scale_low",
"market_orb_quality_size_scale_high",
"market_orb_quality_primary_strong_above",
"market_orb_quality_secondary_weak_above",
"market_orb_quality_secondary_weak_below",
"market_orb_quality_divergence_scale",
"market_orb_quality_primary_weak_below",
"market_orb_quality_primary_weak_above",
"market_orb_quality_secondary_strong_above",
"market_orb_quality_primary_weak_secondary_strong_scale",
"market_orb_quality_primary_lag_above",
"market_orb_quality_primary_lag_below",
"market_orb_quality_secondary_lead_above",
"market_orb_quality_secondary_lead_below",
"market_orb_quality_primary_lag_secondary_lead_scale",
"market_orb_quality_joint_weak_primary_below",
"market_orb_quality_joint_weak_primary_above",
"market_orb_quality_joint_weak_secondary_below",
"market_orb_quality_joint_weak_secondary_above",
"market_orb_quality_joint_weak_scale",
"market_orb_quality_joint_panic_primary_below",
"market_orb_quality_joint_panic_secondary_below",
"market_orb_quality_joint_panic_scale",
)
)
if (
uses_market_orb_quality
or bool(getattr(params, "market_orb_quality_joint_weak_require_confirmation", False))
or bool(getattr(params, "market_orb_quality_joint_panic_require_confirmation", False))
):
quality_ticker = (
getattr(params, "market_orb_quality_ticker", None)
or getattr(params, "market_regime_ticker", None)
or "SPY"
)
if quality_ticker:
support.add(str(quality_ticker).upper())
secondary_quality_ticker = getattr(params, "market_orb_quality_secondary_ticker", None)
if secondary_quality_ticker:
support.add(str(secondary_quality_ticker).upper())
conditional_confirmation_ticker = getattr(params, "conditional_confirmation_ticker", None)
if conditional_confirmation_ticker:
support.add(str(conditional_confirmation_ticker).upper())
if _orb_strategy_uses_idle_sleeve(params):
for ticker in getattr(params, "orb_idle_sleeve_parking_symbols", []) or []:
if ticker:
support.add(str(ticker).upper())
for attr in (
"orb_idle_sleeve_parking_base_symbol",
"orb_idle_sleeve_parking_overlay_symbol",
"orb_idle_sleeve_parking_defensive_symbol",
):
ticker = getattr(params, attr, None)
if ticker:
support.add(str(ticker).upper())
for ticker in getattr(params, "orb_idle_sleeve_risk_off_symbols", []) or []:
if ticker:
support.add(str(ticker).upper())
for ticker in getattr(params, "orb_idle_sleeve_sector_rotation_symbols", []) or []:
if ticker:
support.add(str(ticker).upper())
market_ticker = getattr(params, "orb_idle_sleeve_market_ticker", None)
if market_ticker:
support.add(str(market_ticker).upper())
return sorted(support)
def _orb_strategy_uses_idle_sleeve(params: ORBStrategyParams) -> bool:
return bool(getattr(params, "orb_idle_sleeve_enabled", False))
def _augment_candidate_map_with_support_tickers(
candidates: dict[str, list[str]],
support_tickers: list[str],
trading_days: list[str] | None = None,
*,
include_empty_days: bool = False,
) -> dict[str, list[str]]:
"""Append support tickers to each existing candidate day without duplicating names."""
if not support_tickers:
return candidates
result: dict[str, list[str]] = {}
days = trading_days if include_empty_days and trading_days is not None else list(candidates.keys())
for day in days:
day_tickers = candidates.get(day, [])
merged = list(day_tickers)
seen = set(merged)
for ticker in support_tickers:
if ticker not in seen:
merged.append(ticker)
seen.add(ticker)
result[day] = merged
return result
def _merge_candidate_maps(candidate_maps: list[dict[str, list[str]]]) -> dict[str, list[str]]:
"""Union multiple candidate maps day-by-day without duplicating tickers."""
merged_by_day: dict[str, list[str]] = {}
for candidate_map in candidate_maps:
for day, tickers in candidate_map.items():
day_list = merged_by_day.setdefault(day, [])
seen = set(day_list)
for ticker in tickers:
if ticker not in seen:
day_list.append(ticker)
seen.add(ticker)
return merged_by_day
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
def _append_synthetic_today_daily_rows(
daily_bars: dict[str, list[dict]],
trading_days: list[str],
) -> int:
"""Add a placeholder row for the current ET trading day when daily data is stale.
Same-day daily endpoints often lag intraday/today. The placeholder only makes
prior-bar enrichment keys exist; actual entry open/gap still comes from intraday bars.
"""
if not trading_days:
return 0
today_et = to_eastern(utc_now()).date().isoformat()
if today_et not in set(trading_days):
return 0
added = 0
for ticker, bars in list(daily_bars.items()):
if not bars:
continue
sorted_bars = sorted(bars, key=lambda bar: str(bar.get("date", ""))[:10])
if any(str(bar.get("date", ""))[:10] == today_et for bar in sorted_bars):
daily_bars[ticker] = sorted_bars
continue
prior_bars = [
bar
for bar in sorted_bars
if str(bar.get("date", ""))[:10] < today_et
]
if not prior_bars:
daily_bars[ticker] = sorted_bars
continue
last = prior_bars[-1]
prev_close = float(last.get("close", 0.0) or 0.0)
if prev_close <= 0:
daily_bars[ticker] = sorted_bars
continue
daily_bars[ticker] = sorted_bars + [
{
"date": today_et,
"open": prev_close,
"high": prev_close,
"low": prev_close,
"close": prev_close,
"volume": 0.0,
"synthetic_today_daily": True,
}
]
added += 1
return added
# ── 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 = _build_daily_cache(config) if config.cache.enabled else None
orb_snapshot_id = (
getattr(config.orb_strategy, "prior_event_snapshot_id", None)
if is_orb and config.orb_strategy is not None
else None
)
event_cache = (
FilingEventCache(str(Path(config.cache.dir).with_name("orb_catalyst")))
if config.cache.enabled else None
)
prior_event_snapshot_cache = PriorEventFeatureSnapshotCache(
str(Path(config.cache.dir).with_name("orb_prior_event")),
snapshot_id=orb_snapshot_id,
)
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_strategy_requires_regime_ticker_daily(orb_params) 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...")
synthetic_today_rows = _append_synthetic_today_daily_rows(daily_bars, trading_days)
if synthetic_today_rows:
print(
" Added synthetic same-day daily rows "
f"for {synthetic_today_rows} tickers"
)
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
orb_ownership_features: dict[str, dict[str, dict]] | None = None
orb_form4_features: dict[str, dict[str, dict]] | None = None
if _orb_strategy_uses_ownership_seed_overlay(orb_params):
all_tickers = sorted(str(ticker).upper() for ticker in daily_bars.keys())
print(
" Loading PIT 13D/13G ownership seed features "
f"(D-{int(getattr(orb_params, 'ownership_13dg_lookback_days', 0) or 0)}) "
f"for {len(all_tickers)} tickers..."
)
orb_ownership_features = _load_orb_ownership_13dg_features(
all_tickers,
trading_days,
orb_params,
)
_merge_orb_ownership_13dg_features(enrichment, orb_ownership_features)
print(f" 13D/13G seed coverage: {len(orb_ownership_features)} tickers with events")
if _orb_strategy_uses_form4_seed_overlay(orb_params):
all_tickers = sorted(str(ticker).upper() for ticker in daily_bars.keys())
print(
" Loading PIT Form 4 seed features "
f"(D-{int(getattr(orb_params, 'form4_lookback_days', 0) or 0)}) "
f"for {len(all_tickers)} tickers..."
)
orb_form4_features = _load_orb_form4_features(
all_tickers,
trading_days,
orb_params,
)
_merge_orb_form4_features(enrichment, orb_form4_features)
print(f" Form 4 seed coverage: {len(orb_form4_features)} tickers with events")
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
or int(getattr(orb_params, "candidate_seed_moderate_liquid_overlay_slots", 0) or 0) > 0
or int(getattr(orb_params, "candidate_seed_event_overlay_slots", 0) or 0) > 0
or _orb_strategy_uses_ownership_seed_overlay(orb_params)
or _orb_strategy_uses_form4_seed_overlay(orb_params)
):
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})
_prior_event_types = tuple(getattr(orb_params, "prior_event_types", None) or ["earnings_release", "guidance_update"])
print(
" Prefetching prior-event features "
f"(snapshot={prior_event_snapshot_cache.snapshot_id}, "
f"D-{_prior_lookback}, types={_prior_event_types}) "
f"for {len(all_tickers)} tickers..."
)
event_features = await _prefetch_prior_event_features_db(
all_tickers, trading_days, lookback_calendar_days=_prior_lookback,
event_types=_prior_event_types,
snapshot_cache=prior_event_snapshot_cache,
require_event_details=_orb_prior_event_decay_enabled(orb_params),
)
event_features = _apply_orb_prior_event_decay_features(
event_features,
orb_params,
)
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_ownership_13dg(orb_params) and orb_ownership_features is None:
all_tickers = list({t for day_tickers in candidates.values() for t in day_tickers})
print(
" Loading PIT 13D/13G ownership features "
f"(D-{int(getattr(orb_params, 'ownership_13dg_lookback_days', 0) or 0)}) "
f"for {len(all_tickers)} tickers..."
)
ownership_features = _load_orb_ownership_13dg_features(
all_tickers,
trading_days,
orb_params,
)
_merge_orb_ownership_13dg_features(enrichment, ownership_features)
print(f" 13D/13G coverage: {len(ownership_features)} tickers with events")
if _orb_strategy_uses_form4(orb_params) and orb_form4_features is None:
all_tickers = list({t for day_tickers in candidates.values() for t in day_tickers})
print(
" Loading PIT Form 4 features "
f"(D-{int(getattr(orb_params, 'form4_lookback_days', 0) or 0)}) "
f"for {len(all_tickers)} tickers..."
)
form4_features = _load_orb_form4_features(
all_tickers,
trading_days,
orb_params,
)
_merge_orb_form4_features(enrichment, form4_features)
print(f" Form 4 coverage: {len(form4_features)} tickers with events")
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)
candidates = _augment_candidate_map_with_support_tickers(
candidates,
_orb_intraday_support_tickers(orb_params),
trading_days,
include_empty_days=_orb_strategy_uses_idle_sleeve(orb_params),
)
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()
next_day_after_chunk_idx = simulated_days + len(day_chunk)
next_day_after_chunk = (
trading_days[next_day_after_chunk_idx]
if next_day_after_chunk_idx < len(trading_days)
else None
)
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,
next_trading_day_after_window=next_day_after_chunk,
)
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 (
apply_overrides,
generate_combinations,
load_sweep_config,
run_sweep,
)
import copy
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 = _build_daily_cache(config) if config.cache.enabled else None
orb_snapshot_id = (
getattr(config.orb_strategy, "prior_event_snapshot_id", None)
if is_orb and config.orb_strategy is not None
else None
)
event_cache = (
FilingEventCache(str(Path(config.cache.dir).with_name("orb_catalyst")))
if config.cache.enabled else None
)
prior_event_snapshot_cache = PriorEventFeatureSnapshotCache(
str(Path(config.cache.dir).with_name("orb_prior_event")),
snapshot_id=orb_snapshot_id,
)
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
orb_sweep_overlay_tickers_per_day: dict[str, set[str]] | None = None
orb_sweep_context_resolver = 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()
orb_candidate_variant_param_keys = (
"min_price",
"min_atr_14",
"min_avg_dollar_volume",
"candidate_seed_liquid_overlay_slots",
"candidate_seed_liquid_min_gap_pct",
"candidate_seed_liquid_max_gap_pct",
"candidate_seed_liquid_min_avg_dollar_vol_30d",
"candidate_seed_liquid_min_ret_5d",
"candidate_seed_liquid_max_entropy_20d",
"candidate_seed_leader_overlay_slots",
"candidate_seed_leader_min_gap_pct",
"candidate_seed_leader_max_gap_pct",
"candidate_seed_leader_min_avg_dollar_vol_30d",
"candidate_seed_leader_min_ret_5d",
"candidate_seed_leader_min_atr_pct",
"candidate_seed_leader_max_entropy_20d",
"candidate_seed_moderate_liquid_overlay_slots",
"candidate_seed_moderate_liquid_min_gap_pct",
"candidate_seed_moderate_liquid_max_gap_pct",
"candidate_seed_moderate_liquid_min_avg_dollar_vol_30d",
"candidate_seed_moderate_liquid_max_avg_dollar_vol_30d",
"candidate_seed_moderate_liquid_min_ret_5d",
"candidate_seed_moderate_liquid_max_entropy_20d",
"candidate_seed_event_overlay_slots",
"candidate_seed_event_min_score",
"candidate_seed_event_min_gap_pct",
"candidate_seed_event_max_gap_pct",
"candidate_seed_event_min_avg_dollar_vol_30d",
"candidate_seed_event_min_ret_5d",
"candidate_seed_event_max_entropy_20d",
"candidate_allowed_event_types",
"candidate_seed_ownership_overlay_slots",
"candidate_seed_ownership_initial_only",
"candidate_seed_ownership_min_strength_score",
"candidate_seed_ownership_min_gap_pct",
"candidate_seed_ownership_max_gap_pct",
"candidate_seed_ownership_min_avg_dollar_vol_30d",
"candidate_seed_ownership_min_ret_5d",
"candidate_seed_ownership_max_entropy_20d",
"candidate_seed_form4_overlay_slots",
"candidate_seed_form4_min_total_value",
"candidate_seed_form4_min_owner_count",
"candidate_seed_form4_min_c_suite_count",
"candidate_seed_form4_require_cluster_or_csuite",
"candidate_seed_form4_min_gap_pct",
"candidate_seed_form4_max_gap_pct",
"candidate_seed_form4_min_avg_dollar_vol_30d",
"candidate_seed_form4_min_ret_5d",
"candidate_seed_form4_max_entropy_20d",
)
orb_sweep_dynamic_candidate_params = any(
key in sweep.sweep_params for key in orb_candidate_variant_param_keys
)
orb_sweep_dynamic_quality_params = any(
key in sweep.sweep_params
for key in (
"market_orb_quality_ticker",
"market_orb_quality_secondary_ticker",
"market_orb_quality_size_scale_low",
"market_orb_quality_size_scale_high",
"market_orb_quality_size_scale_min",
"market_orb_quality_size_scale_max",
"conditional_confirmation_ticker",
"market_orb_quality_joint_weak_require_confirmation",
"market_orb_quality_joint_panic_require_confirmation",
)
)
orb_ownership_param_keys = (
"ownership_13dg_lookback_days",
"ownership_13dg_reference_path",
"weight_ownership_13dg",
"weight_ownership_initial_13dg",
"ownership_initial_size_scale",
"ownership_initial_min_score_rank_pct",
"ownership_initial_allowed_trigger_types",
"ownership_initial_ignore_scaled_risk_overlays",
"candidate_seed_ownership_overlay_slots",
"candidate_seed_ownership_initial_only",
"candidate_seed_ownership_min_strength_score",
"candidate_seed_ownership_min_gap_pct",
"candidate_seed_ownership_max_gap_pct",
"candidate_seed_ownership_min_avg_dollar_vol_30d",
"candidate_seed_ownership_min_ret_5d",
"candidate_seed_ownership_max_entropy_20d",
)
orb_ownership_seed_param_keys = (
"candidate_seed_ownership_overlay_slots",
"candidate_seed_ownership_initial_only",
"candidate_seed_ownership_min_strength_score",
"candidate_seed_ownership_min_gap_pct",
"candidate_seed_ownership_max_gap_pct",
"candidate_seed_ownership_min_avg_dollar_vol_30d",
"candidate_seed_ownership_min_ret_5d",
"candidate_seed_ownership_max_entropy_20d",
)
orb_form4_seed_param_keys = (
"candidate_seed_form4_overlay_slots",
"candidate_seed_form4_min_total_value",
"candidate_seed_form4_min_owner_count",
"candidate_seed_form4_min_c_suite_count",
"candidate_seed_form4_require_cluster_or_csuite",
"candidate_seed_form4_min_gap_pct",
"candidate_seed_form4_max_gap_pct",
"candidate_seed_form4_min_avg_dollar_vol_30d",
"candidate_seed_form4_min_ret_5d",
"candidate_seed_form4_max_entropy_20d",
)
orb_sweep_uses_ownership_13dg = (
_orb_strategy_uses_ownership_13dg(orb_params_sweep_check)
or any(key in sweep.sweep_params for key in orb_ownership_param_keys)
)
orb_sweep_uses_ownership_seed_overlay = (
_orb_strategy_uses_ownership_seed_overlay(orb_params_sweep_check)
or any(key in sweep.sweep_params for key in orb_ownership_seed_param_keys)
)
orb_form4_param_keys = (
"form4_lookback_days",
"form4_reference_path",
"form4_size_scale",
"form4_min_total_value",
"form4_min_owner_count",
"form4_min_c_suite_count",
"form4_require_cluster_or_csuite",
"form4_allowed_trigger_types",
"form4_ignore_scaled_risk_overlays",
*orb_form4_seed_param_keys,
)
orb_sweep_uses_form4_seed_overlay = (
_orb_strategy_uses_form4_seed_overlay(orb_params_sweep_check)
or any(key in sweep.sweep_params for key in orb_form4_seed_param_keys)
)
orb_sweep_uses_form4 = (
_orb_strategy_uses_form4(orb_params_sweep_check)
or any(key in sweep.sweep_params for key in orb_form4_param_keys)
)
orb_sweep_dynamic_event_params = any(
key in sweep.sweep_params
for key in (
"prior_event_lookback_days",
"prior_event_types",
"prior_event_decay_half_life_days",
"prior_event_decay_min_score",
"prior_event_guidance_score_scale",
)
)
orb_sweep_requires_event_details = any(
key in sweep.sweep_params
for key in (
"prior_event_decay_half_life_days",
"prior_event_decay_min_score",
"prior_event_guidance_score_scale",
)
) or _orb_prior_event_decay_enabled(orb_params_sweep_check)
def _orb_candidate_variant_key(params: ORBStrategyParams) -> tuple[object, ...]:
return tuple(
tuple(value) if isinstance(value, list) else value
for value in (
getattr(params, key, None)
for key in orb_candidate_variant_param_keys
)
)
regime_ticker_sw = getattr(orb_params_sweep_check, "market_regime_ticker", "SPY") or "SPY"
if _orb_strategy_requires_regime_ticker_daily(orb_params_sweep_check) 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)
orb_params = config.orb_strategy or ORBStrategyParams()
print(" Computing ATR/volume enrichment...")
synthetic_today_rows_sw = _append_synthetic_today_daily_rows(daily_bars, trading_days)
if synthetic_today_rows_sw:
print(
" Added synthetic same-day daily rows "
f"for {synthetic_today_rows_sw} tickers"
)
enrichment = enrich_daily_bars(daily_bars, trading_days)
orb_sweep_ownership_features: dict[str, dict[str, dict]] | None = None
orb_sweep_form4_features: dict[str, dict[str, dict]] | None = None
if orb_sweep_uses_ownership_seed_overlay:
ownership_params = orb_params
if "ownership_13dg_lookback_days" in sweep.sweep_params:
lookbacks = [
int(value or 0)
for value in sweep.sweep_params.get("ownership_13dg_lookback_days", [])
]
if lookbacks:
ownership_params = ownership_params.model_copy(
update={"ownership_13dg_lookback_days": max(lookbacks)}
)
if int(getattr(ownership_params, "ownership_13dg_lookback_days", 0) or 0) > 0:
all_daily_tickers = sorted(str(ticker).upper() for ticker in daily_bars.keys())
print(
" Loading PIT 13D/13G ownership seed features "
f"(D-{int(getattr(ownership_params, 'ownership_13dg_lookback_days', 0) or 0)}) "
f"for {len(all_daily_tickers)} tickers..."
)
orb_sweep_ownership_features = _load_orb_ownership_13dg_features(
all_daily_tickers,
trading_days,
ownership_params,
)
_merge_orb_ownership_13dg_features(
enrichment,
orb_sweep_ownership_features,
)
print(
f" 13D/13G seed coverage: {len(orb_sweep_ownership_features)} "
"tickers with events"
)
if orb_sweep_uses_form4_seed_overlay:
form4_params = orb_params
if "form4_lookback_days" in sweep.sweep_params:
lookbacks = [
int(value or 0)
for value in sweep.sweep_params.get("form4_lookback_days", [])
]
if lookbacks:
form4_params = form4_params.model_copy(
update={"form4_lookback_days": max(lookbacks)}
)
if int(getattr(form4_params, "form4_lookback_days", 0) or 0) > 0:
all_daily_tickers = sorted(str(ticker).upper() for ticker in daily_bars.keys())
print(
" Loading PIT Form 4 seed features "
f"(D-{int(getattr(form4_params, 'form4_lookback_days', 0) or 0)}) "
f"for {len(all_daily_tickers)} tickers..."
)
orb_sweep_form4_features = _load_orb_form4_features(
all_daily_tickers,
trading_days,
form4_params,
)
_merge_orb_form4_features(enrichment, orb_sweep_form4_features)
print(
f" Form 4 seed coverage: {len(orb_sweep_form4_features)} "
"tickers with events"
)
orb_base_enrichment = (
copy.deepcopy(enrichment)
if orb_sweep_dynamic_event_params
else None
)
candidates = orb_pre_screen_candidates(
daily_bars, trading_days, enrichment,
min_price=orb_params.min_price,
min_atr=orb_params.min_atr_14,
min_avg_dollar_vol=orb_params.min_avg_dollar_volume,
max_per_day=None,
)
if (
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
or int(getattr(orb_params, "candidate_seed_moderate_liquid_overlay_slots", 0) or 0) > 0
or int(getattr(orb_params, "candidate_seed_event_overlay_slots", 0) or 0) > 0
or _orb_strategy_uses_ownership_seed_overlay(orb_params)
or _orb_strategy_uses_form4_seed_overlay(orb_params)
):
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
)
orb_candidate_variant_maps: dict[tuple[float, float, float], dict[str, list[str]]] = {}
orb_overlay_variant_maps: dict[tuple[float, float, float], dict[str, set[str]] | None] = {}
if orb_sweep_dynamic_candidate_params:
for overrides in generate_combinations(sweep):
combo_config = apply_overrides(config, overrides)
combo_params = combo_config.orb_strategy or ORBStrategyParams()
variant_key = _orb_candidate_variant_key(combo_params)
if variant_key in orb_candidate_variant_maps:
continue
combo_candidates = orb_pre_screen_candidates(
daily_bars,
trading_days,
enrichment,
min_price=combo_params.min_price,
min_atr=combo_params.min_atr_14,
min_avg_dollar_vol=combo_params.min_avg_dollar_volume,
max_per_day=None,
)
combo_overlay_tickers: dict[str, set[str]] | None = None
if (
int(getattr(combo_params, "candidate_seed_leader_overlay_slots", 0) or 0) > 0
or int(getattr(combo_params, "candidate_seed_liquid_overlay_slots", 0) or 0) > 0
or int(getattr(combo_params, "candidate_seed_moderate_liquid_overlay_slots", 0) or 0) > 0
or int(getattr(combo_params, "candidate_seed_event_overlay_slots", 0) or 0) > 0
or _orb_strategy_uses_ownership_seed_overlay(combo_params)
or _orb_strategy_uses_form4_seed_overlay(combo_params)
):
combo_candidates, combo_overlay_tickers = _augment_momentum_seed_candidates_with_liquid_overlay( # type: ignore[arg-type]
combo_candidates,
daily_bars,
trading_days,
enrichment,
combo_params,
)
orb_candidate_variant_maps[variant_key] = _normalize_candidate_map(combo_candidates)
orb_overlay_variant_maps[variant_key] = combo_overlay_tickers
if orb_sweep_dynamic_candidate_params and orb_candidate_variant_maps:
candidates = _merge_candidate_maps(list(orb_candidate_variant_maps.values()))
all_tickers_sw = list({t for day_tickers in candidates.values() for t in day_tickers})
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:
_prior_event_types_sw = tuple(getattr(orb_params, "prior_event_types", None) or ["earnings_release", "guidance_update"])
print(
" Prefetching prior-event features "
f"(snapshot={prior_event_snapshot_cache.snapshot_id}, "
f"D-{_prior_lookback_sw}, types={_prior_event_types_sw}) "
f"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,
event_types=_prior_event_types_sw,
snapshot_cache=prior_event_snapshot_cache,
require_event_details=orb_sweep_requires_event_details,
)
event_features = _apply_orb_prior_event_decay_features(
event_features,
orb_params,
)
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_sweep_dynamic_event_params:
event_enrichment_variants: dict[tuple[object, ...], dict[str, dict[str, dict]]] = {}
unique_event_variants: dict[tuple[object, ...], ORBStrategyParams] = {}
def _orb_event_variant_key(params: ORBStrategyParams) -> tuple[object, ...]:
return (
int(getattr(params, "prior_event_lookback_days", 0) or 0),
tuple(getattr(params, "prior_event_types", None) or ["earnings_release", "guidance_update"]),
getattr(params, "prior_event_decay_half_life_days", None),
getattr(params, "prior_event_decay_min_score", None),
float(getattr(params, "prior_event_guidance_score_scale", 1.0) or 1.0),
)
for overrides in generate_combinations(sweep):
combo_config = apply_overrides(config, overrides)
combo_params = combo_config.orb_strategy or ORBStrategyParams()
if not _orb_strategy_uses_catalyst(combo_params):
continue
combo_lookback = int(getattr(combo_params, "prior_event_lookback_days", 0) or 0)
if combo_lookback <= 0:
continue
unique_event_variants[_orb_event_variant_key(combo_params)] = combo_params
if unique_event_variants:
print(
" Prefetching sweep prior-event enrichment variants "
f"for {len(unique_event_variants)} setups..."
)
base_variant_key = _orb_event_variant_key(orb_params)
for variant_key, combo_params in sorted(
unique_event_variants.items(),
key=lambda item: repr(item[0]),
):
combo_lookback = int(variant_key[0] or 0)
combo_types = tuple(variant_key[1] or ())
if combo_lookback <= 0:
continue
if variant_key == base_variant_key:
event_enrichment_variants[variant_key] = enrichment
continue
variant_features = await _prefetch_prior_event_features_db(
all_tickers_sw,
trading_days,
lookback_calendar_days=combo_lookback,
event_types=combo_types,
snapshot_cache=prior_event_snapshot_cache,
require_event_details=_orb_prior_event_decay_enabled(combo_params),
)
variant_features = _apply_orb_prior_event_decay_features(
variant_features,
combo_params,
)
variant_enrichment = copy.deepcopy(orb_base_enrichment if orb_base_enrichment is not None else enrichment)
_merge_orb_event_features(variant_enrichment, variant_features)
event_enrichment_variants[variant_key] = variant_enrichment
if _orb_strategy_uses_attention(orb_params_sweep_check):
attention_pairs = _orb_candidate_event_pairs(candidates, enrichment, orb_params)
print(f" Fetching event 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_orb_attention_features(enrichment, attention_features)
if orb_base_enrichment is not None:
_merge_orb_attention_features(orb_base_enrichment, attention_features)
if orb_sweep_dynamic_event_params:
for variant_enrichment in event_enrichment_variants.values():
if variant_enrichment is not enrichment:
_merge_orb_attention_features(
variant_enrichment,
attention_features,
)
if orb_sweep_uses_ownership_13dg and orb_sweep_ownership_features is None:
ownership_params = orb_params
if "ownership_13dg_lookback_days" in sweep.sweep_params:
lookbacks = [
int(value or 0)
for value in sweep.sweep_params.get("ownership_13dg_lookback_days", [])
]
if lookbacks:
ownership_params = ownership_params.model_copy(
update={"ownership_13dg_lookback_days": max(lookbacks)}
)
if int(getattr(ownership_params, "ownership_13dg_lookback_days", 0) or 0) > 0:
print(
" Loading PIT 13D/13G ownership features "
f"(D-{int(getattr(ownership_params, 'ownership_13dg_lookback_days', 0) or 0)}) "
f"for {len(all_tickers_sw)} tickers..."
)
ownership_features = _load_orb_ownership_13dg_features(
all_tickers_sw,
trading_days,
ownership_params,
)
_merge_orb_ownership_13dg_features(enrichment, ownership_features)
if orb_base_enrichment is not None:
_merge_orb_ownership_13dg_features(
orb_base_enrichment,
ownership_features,
)
if orb_sweep_dynamic_event_params:
for variant_enrichment in event_enrichment_variants.values():
if variant_enrichment is not enrichment:
_merge_orb_ownership_13dg_features(
variant_enrichment,
ownership_features,
)
print(f" 13D/13G coverage: {len(ownership_features)} tickers with events")
if orb_sweep_uses_form4 and orb_sweep_form4_features is None:
form4_params = orb_params
if "form4_lookback_days" in sweep.sweep_params:
lookbacks = [
int(value or 0)
for value in sweep.sweep_params.get("form4_lookback_days", [])
]
if lookbacks:
form4_params = form4_params.model_copy(
update={"form4_lookback_days": max(lookbacks)}
)
if int(getattr(form4_params, "form4_lookback_days", 0) or 0) > 0:
print(
" Loading PIT Form 4 features "
f"(D-{int(getattr(form4_params, 'form4_lookback_days', 0) or 0)}) "
f"for {len(all_tickers_sw)} tickers..."
)
form4_features = _load_orb_form4_features(
all_tickers_sw,
trading_days,
form4_params,
)
_merge_orb_form4_features(enrichment, form4_features)
if orb_base_enrichment is not None:
_merge_orb_form4_features(
orb_base_enrichment,
form4_features,
)
if orb_sweep_dynamic_event_params:
for variant_enrichment in event_enrichment_variants.values():
if variant_enrichment is not enrichment:
_merge_orb_form4_features(
variant_enrichment,
form4_features,
)
print(f" Form 4 coverage: {len(form4_features)} tickers with events")
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)
orb_support_tickers = set(_orb_intraday_support_tickers(orb_params))
if orb_sweep_dynamic_quality_params:
for overrides in generate_combinations(sweep):
combo_config = apply_overrides(config, overrides)
combo_params = combo_config.orb_strategy or ORBStrategyParams()
orb_support_tickers.update(_orb_intraday_support_tickers(combo_params))
if orb_sweep_dynamic_candidate_params and orb_candidate_variant_maps:
for variant_key, candidate_map in list(orb_candidate_variant_maps.items()):
orb_candidate_variant_maps[variant_key] = _augment_candidate_map_with_support_tickers(
candidate_map,
sorted(orb_support_tickers),
trading_days,
include_empty_days=_orb_strategy_uses_idle_sleeve(orb_params),
)
candidates = _merge_candidate_maps(list(orb_candidate_variant_maps.values()))
else:
candidates = _augment_candidate_map_with_support_tickers(
candidates,
sorted(orb_support_tickers),
trading_days,
include_empty_days=_orb_strategy_uses_idle_sleeve(orb_params),
)
if orb_sweep_dynamic_event_params or orb_sweep_dynamic_candidate_params:
def orb_sweep_context_resolver(
combo_config: IntradayConfig,
) -> tuple[
dict[str, dict[str, dict]],
dict[str, float] | None,
dict[str, set[str]] | None,
dict[str, list[str]] | None,
]:
combo_params = combo_config.orb_strategy or ORBStrategyParams()
if orb_sweep_dynamic_event_params:
if not _orb_strategy_uses_catalyst(combo_params):
combo_enrichment = orb_base_enrichment if orb_base_enrichment is not None else enrichment
else:
combo_lookback = int(getattr(combo_params, "prior_event_lookback_days", 0) or 0)
combo_key = _orb_event_variant_key(combo_params)
combo_enrichment = (
event_enrichment_variants.get(combo_key, enrichment)
if combo_lookback > 0
else enrichment
)
else:
combo_enrichment = enrichment
combo_candidate_map = None
combo_overlay_tickers = orb_sweep_overlay_tickers_per_day
if orb_sweep_dynamic_candidate_params:
variant_key = _orb_candidate_variant_key(combo_params)
combo_candidate_map = orb_candidate_variant_maps.get(variant_key)
combo_overlay_tickers = orb_overlay_variant_maps.get(variant_key)
return combo_enrichment, momentum_vix_by_day, combo_overlay_tickers, combo_candidate_map
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,
sector_proxy_intraday_by_day=sector_proxy_intraday,
overlay_tickers_per_day=orb_sweep_overlay_tickers_per_day if is_orb else None,
orb_context_resolver=orb_sweep_context_resolver if is_orb else None,
)
print()
# Display top results
print(format_sweep_comparison(sweep_results, top_n=20))
# Also show full details for the #1 configuration
if sweep_results:
best = sweep_results[0]
best_config = apply_overrides(config, best.params)
if is_orb:
best_enrichment = enrichment or {}
best_vix = momentum_vix_by_day
best_overlay = orb_sweep_overlay_tickers_per_day
best_candidate_map = None
if orb_sweep_context_resolver is not None:
resolved = orb_sweep_context_resolver(best_config)
if len(resolved) == 4:
best_enrichment, best_vix, best_overlay, best_candidate_map = resolved
else:
best_enrichment, best_vix, best_overlay = resolved
from libs.intraday.orb_simulator import run_orb_simulation
best_all_intraday = all_intraday
if best_candidate_map:
best_all_intraday = {
day: {
ticker: day_intraday[ticker]
for ticker in tickers
if ticker in day_intraday
}
for day, tickers in best_candidate_map.items()
if (day_intraday := all_intraday.get(day))
}
best_day_results = run_orb_simulation(
best_all_intraday, trading_days, best_config.orb_strategy, best_enrichment,
ticker_sectors=ticker_sectors,
vix_by_day=best_vix,
overlay_tickers_per_day=best_overlay,
)
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(),
"objective_score": sr.objective_score,
}
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 (복리) and disable daily budget reset unless explicitly set")
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 and buying power to initial_capital every day "
"(does not compound 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,
data_provenance=_build_intraday_data_provenance(config),
)
print(f"\nResults saved to: {out_file}")
def main() -> None:
asyncio.run(main_async())
if __name__ == "__main__":
main()