"""Morning Momentum Intraday Backtester — Main Entry Point.

Usage:
    # Default run (sp500, 40 trading days, default params)
    python -m apps.intraday_bt.run

    # Override params inline
    python -m apps.intraday_bt.run --days 200 --universe midlarge --top-n 5 --stop-loss -0.03

    # Custom config file
    python -m apps.intraday_bt.run --config configs/intraday/default.yaml

    # Parameter sweep
    python -m apps.intraday_bt.run --sweep configs/intraday/sweep_basic.yaml

    # Cache management
    python -m apps.intraday_bt.run --no-cache
    python -m apps.intraday_bt.run --refresh-cache

    # Verbose daily output
    python -m apps.intraday_bt.run --verbose
"""
from __future__ import annotations

import argparse
import asyncio
import json
import pickle
import re
import sys
import uuid
from datetime import date, timedelta
from pathlib import Path

import yaml

from apps.intraday_bt.oracle import make_intraday_oracle_client
from libs.common.config import get_settings
from libs.common.time_utils import is_trading_day, to_eastern, trading_days_between, utc_now
from libs.intraday.cache import DailyBarCache, IntradayCache
from libs.intraday.catalyst import (
    AttentionEventCache,
    FilingEventCache,
    fetch_attention_features_bulk,
    fetch_filing_event_features_bulk,
)
from libs.intraday.domain import (
    BacktestParams,
    CacheParams,
    IntradayConfig,
    ORBStrategyParams,
    OutputParams,
    StrategyParams,
    UniverseParams,
)
from libs.intraday.features import compute_gap_pct, enrich_daily_bars
from libs.intraday.metrics import (
    compute_metrics,
    format_daily_breakdown,
    format_summary,
    format_sweep_comparison,
    format_top_trades,
    write_results,
)
from libs.intraday.screener import (
    fetch_daily_bars_bulk,
    fetch_intraday_bulk,
    momentum_intraday_first_candidates,
    momentum_pre_screen_candidates,
    orb_pre_screen_candidates,
    pre_screen_candidates,
    resolve_universe,
)
from libs.intraday.simulator import (
    SECTOR_PROXY_TICKERS,
    _bar_at_offset,
    _dollar_volume_up_to_bar,
    _market_open_ts,
    _volume_up_to_bar,
    filter_market_hours,
    run_simulation,
)
from libs.oracle_client import CompanyService
from libs.oracle_client.fred import FredService


# ── Config Loading ─────────────────────────────────────────────────────────


def load_config(path: str | None) -> IntradayConfig:
    """Load config from YAML file or return defaults."""
    if path is None:
        default_path = Path("configs/intraday/default.yaml")
        if default_path.exists():
            path = str(default_path)
        else:
            return IntradayConfig()

    with open(path) as f:
        raw = yaml.safe_load(f) or {}

    strategy_mode = raw.get("strategy_mode", "momentum")
    strategy = StrategyParams(**raw.get("strategy", {}))
    universe = UniverseParams(**raw.get("universe", {}))
    backtest = BacktestParams(**raw.get("backtest", {}))
    cache = CacheParams(**raw.get("cache", {}))
    output = OutputParams(**raw.get("output", {}))

    orb_strategy = None
    if strategy_mode == "orb":
        orb_strategy = ORBStrategyParams(**raw.get("orb_strategy", {}))

    return IntradayConfig(
        strategy_mode=strategy_mode,
        strategy=strategy,
        orb_strategy=orb_strategy,
        universe=universe,
        backtest=backtest,
        cache=cache,
        output=output,
    )


def apply_cli_overrides(config: IntradayConfig, args: argparse.Namespace) -> IntradayConfig:
    """Apply CLI argument overrides to config."""
    strategy_dict = config.strategy.model_dump()
    universe_dict = config.universe.model_dump()
    backtest_dict = config.backtest.model_dump()
    cache_dict = config.cache.model_dump()
    output_dict = config.output.model_dump()

    if args.days is not None:
        backtest_dict["lookback_trading_days"] = args.days
    if getattr(args, "start", None) is not None:
        backtest_dict["start_date"] = args.start
    if getattr(args, "end", None) is not None:
        backtest_dict["end_date"] = args.end
    if args.universe is not None:
        universe_dict["source"] = args.universe
    if args.top_n is not None:
        strategy_dict["top_n"] = args.top_n
    if args.stop_loss is not None:
        strategy_dict["stop_loss_pct"] = None if args.stop_loss.lower() == "none" else float(args.stop_loss)
    if args.entry_min is not None:
        strategy_dict["entry_minutes_after_open"] = args.entry_min
    if args.exit_min is not None:
        strategy_dict["exit_minutes_before_close"] = args.exit_min
    if args.min_gain is not None:
        strategy_dict["min_morning_gain_pct"] = args.min_gain
    if args.no_cache:
        cache_dict["enabled"] = False
    if args.verbose:
        output_dict["verbose"] = True
    if args.output_dir is not None:
        output_dict["dir"] = args.output_dir

    strategy_mode = config.strategy_mode
    if hasattr(args, "strategy") and args.strategy is not None:
        strategy_mode = args.strategy

    orb_strategy = config.orb_strategy
    if strategy_mode == "orb" and orb_strategy is None:
        orb_strategy = ORBStrategyParams()

    # Override compound_returns / initial_capital / daily_budget_reset via CLI
    compound_override = getattr(args, "compound_returns", None)
    capital_override = getattr(args, "initial_capital", None)
    reset_override = getattr(args, "daily_budget_reset", None)
    if strategy_mode == "orb" and orb_strategy is not None:
        if compound_override is not None or capital_override is not None or reset_override is not None:
            orb_dict = orb_strategy.model_dump()
            if compound_override is not None:
                orb_dict["compound_returns"] = compound_override
            if capital_override is not None:
                orb_dict["initial_capital"] = capital_override
            if reset_override is not None:
                orb_dict["daily_budget_reset"] = reset_override
            orb_strategy = ORBStrategyParams(**orb_dict)
    elif strategy_mode != "orb":
        if compound_override is not None or capital_override is not None or reset_override is not None:
            if compound_override is not None:
                strategy_dict["compound_returns"] = compound_override
            if capital_override is not None:
                strategy_dict["initial_capital"] = capital_override
            if reset_override is not None:
                strategy_dict["daily_budget_reset"] = reset_override

    return IntradayConfig(
        strategy_mode=strategy_mode,
        strategy=StrategyParams(**strategy_dict),
        orb_strategy=orb_strategy,
        universe=UniverseParams(**universe_dict),
        backtest=BacktestParams(**backtest_dict),
        cache=CacheParams(**cache_dict),
        output=OutputParams(**output_dict),
    )


# ── Trading Day Resolution ─────────────────────────────────────────────────


async def get_trading_days(
    client: OracleClient,
    start_date: str | None,
    end_date: str | None,
    lookback: int,
) -> list[str]:
    """Resolve the list of trading days for the backtest period."""
    # Use the local NYSE calendar for deterministic date resolution.
    # This avoids long-range Oracle timeouts when resolving multi-year periods.
    today = _latest_backtest_date()
    if end_date:
        end = date.fromisoformat(end_date)
    else:
        end = today

    if start_date:
        start = date.fromisoformat(start_date)
    else:
        # Fetch extra calendar days to account for weekends/holidays.
        start = end - timedelta(days=lookback * 2)

    days = [d.isoformat() for d in trading_days_between(start, end)]

    # If an explicit start_date was pinned, return all days in range (no lookback cap)
    if start_date:
        return days

    # Otherwise return last N trading days
    return days[-lookback:]


def _latest_backtest_date(now_et=None) -> date:
    """Return the latest completed date safe for historical backtests.

    - Trading day after 16:00 ET: include today
    - Trading day before 16:00 ET: stop at yesterday
    - Non-trading day: use today as upper bound; the NYSE calendar trims to the
      last completed trading session automatically.
    """
    if now_et is None:
        now_et = to_eastern(utc_now())
    today = now_et.date()
    if not is_trading_day(today) or now_et.hour >= 16:
        return today
    return today - timedelta(days=1)


def _latest_completed_trading_day(now_et=None) -> date:
    """Return the most recent completed trading session date."""
    latest = _latest_backtest_date(now_et)
    while not is_trading_day(latest):
        latest -= timedelta(days=1)
    return latest


def _load_ticker_sectors(tickers: list[str]) -> dict[str, str]:
    """Load cached sector labels for intraday basket diversification filters."""
    settings = get_settings()
    path = Path(settings.data_root) / "cache" / "sector_cache.json"
    if not path.exists():
        return {ticker: "UNKNOWN" for ticker in tickers}
    try:
        payload = json.loads(path.read_text())
    except Exception:
        return {ticker: "UNKNOWN" for ticker in tickers}
    return {ticker: str(payload.get(ticker) or "UNKNOWN") for ticker in tickers}


def _write_ticker_sector_cache(payload: dict[str, str]) -> None:
    settings = get_settings()
    path = Path(settings.data_root) / "cache" / "sector_cache.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(dict(sorted(payload.items())), indent=2))


def _sector_info_is_placeholder(info: object) -> bool:
    sector = getattr(info, "sector", None)
    industry = getattr(info, "industry", None)
    exchange = getattr(info, "exchange", None)
    market_cap = getattr(info, "market_cap", None)
    return (
        sector == "Technology"
        and industry == "Software"
        and exchange is None
        and market_cap is None
    )


async def _load_ticker_sectors_with_oracle(
    tickers: list[str],
    client,
    *,
    concurrency: int = 12,
) -> dict[str, str]:
    """Load sector labels from cache, backfilling missing values from Oracle."""
    if not tickers:
        return {}

    result = _load_ticker_sectors(tickers)
    missing = sorted(
        ticker
        for ticker, sector in result.items()
        if not sector or str(sector).upper() == "UNKNOWN"
    )
    if not missing or client is None:
        return result

    settings = get_settings()
    path = Path(settings.data_root) / "cache" / "sector_cache.json"
    try:
        cache_payload = json.loads(path.read_text()) if path.exists() else {}
    except Exception:
        cache_payload = {}

    semaphore = asyncio.Semaphore(max(1, concurrency))
    company_svc = CompanyService(client)

    async def _fetch_sector(ticker: str) -> tuple[str, str | None]:
        async with semaphore:
            try:
                info = await company_svc.get_company(ticker)
            except Exception:
                return ticker, None
            if _sector_info_is_placeholder(info):
                return ticker, None
            sector = str(getattr(info, "sector", None) or "").strip()
            if not sector or sector.upper() == "UNKNOWN":
                return ticker, None
            return ticker, sector

    updated = False
    tasks = [asyncio.create_task(_fetch_sector(ticker)) for ticker in missing]
    for task in asyncio.as_completed(tasks):
        ticker, sector = await task
        if not sector:
            continue
        result[ticker] = sector
        cache_payload[ticker] = sector
        updated = True

    if updated:
        _write_ticker_sector_cache(cache_payload)
    return result


def _load_orb_ticker_sectors(tickers: list[str]) -> dict[str, str]:
    """Backward-compatible alias for ORB paths."""
    return _load_ticker_sectors(tickers)


def _orb_strategy_uses_catalyst(params: ORBStrategyParams) -> bool:
    return (
        params.engine_family == "stocks_in_play_dual_regime"
        or params.require_event_flag
        or params.weight_event_catalyst > 0
    )


def _orb_strategy_uses_attention(params: ORBStrategyParams) -> bool:
    return (
        params.engine_family == "stocks_in_play_dual_regime"
        or params.weight_attention_wiki > 0
        or params.weight_attention_news > 0
        or params.attention_min_wiki_spike_10d is not None
        or params.attention_min_wiki_zscore_20d is not None
        or params.attention_min_article_count_3d is not None
        or params.attention_min_us_article_count_3d is not None
        or params.attention_min_resolver_confidence is not None
    )


def _orb_strategy_uses_vix(params: ORBStrategyParams) -> bool:
    return any(
        value is not None and value != default
        for value, default in [
            (params.max_vix, None),
            (params.vix_size_scale_low, None),
            (params.vix_size_scale_high, None),
        ]
    ) or params.vix_size_scale_min != 1.0


async def _prefetch_prior_event_features_db(
    tickers: list[str],
    trading_days: list[str],
    lookback_calendar_days: int = 7,
    event_types: tuple[str, ...] = ("earnings_release", "guidance_update"),
) -> dict[str, dict[str, dict]]:
    """Bulk-fetch prior earnings/guidance events from DB and build event feature map.

    For each ticker, finds events of the specified types in the DB events table
    within [start - lookback_calendar_days, end], then marks each trading day
    within `lookback_calendar_days` after an event as event_flag=True, event_score=1.0.
    Trading days with no recent event retain event_flag=False, event_score=0.0.
    """
    import asyncpg
    from datetime import datetime, timedelta

    if not tickers or not trading_days:
        return {}

    start_date = trading_days[0]
    end_date = trading_days[-1]

    # Expand lookback window to capture events before the first trading day
    start_dt = datetime.strptime(start_date, "%Y-%m-%d").date()
    end_dt = datetime.strptime(end_date, "%Y-%m-%d").date()

    # symbol_id format: SYM::{TICKER}::US
    symbol_ids = [f"SYM::{t}::US" for t in tickers]
    ticker_from_sid = {f"SYM::{t}::US": t for t in tickers}

    dsn = get_settings().postgres_dsn.replace("+asyncpg", "")
    conn = await asyncpg.connect(dsn=dsn)
    try:
        rows = await conn.fetch(
            """
            SELECT symbol_id, event_date::text AS event_date
            FROM events
            WHERE symbol_id = ANY($1)
              AND event_type = ANY($2)
              AND event_date BETWEEN $3 AND $4
            ORDER BY symbol_id, event_date
            """,
            symbol_ids,
            list(event_types),
            (start_dt - timedelta(days=lookback_calendar_days)),
            end_dt,
        )
    finally:
        await conn.close()

    # Group event dates per ticker
    from collections import defaultdict
    events_by_ticker: dict[str, list[str]] = defaultdict(list)
    for row in rows:
        ticker = ticker_from_sid.get(row["symbol_id"])
        if ticker:
            events_by_ticker[ticker].append(row["event_date"])

    # Build feature map: for each trading day, mark True if within lookback window after an event
    result: dict[str, dict[str, dict]] = {}
    for ticker in tickers:
        event_dates = events_by_ticker.get(ticker, [])
        if not event_dates:
            continue
        ticker_map: dict[str, dict] = {}
        for td_str in trading_days:
            td = datetime.strptime(td_str, "%Y-%m-%d").date()
            # Check if any event falls in [td - lookback_calendar_days, td - 1]
            has_prior_event = False
            for ev_str in event_dates:
                ev = datetime.strptime(ev_str, "%Y-%m-%d").date()
                days_ago = (td - ev).days
                if 1 <= days_ago <= lookback_calendar_days:
                    has_prior_event = True
                    break
            if has_prior_event:
                ticker_map[td_str] = {"event_flag": True, "event_score": 1.0}
        if ticker_map:
            result[ticker] = ticker_map
    return result


def _merge_orb_event_features(
    enrichment: dict[str, dict[str, dict]],
    event_features: dict[str, dict[str, dict]],
) -> None:
    for ticker, day_map in event_features.items():
        ticker_enrichment = enrichment.setdefault(ticker, {})
        for day, features in day_map.items():
            ticker_day = ticker_enrichment.setdefault(day, {})
            ticker_day.update(features)


def _merge_orb_attention_features(
    enrichment: dict[str, dict[str, dict]],
    attention_features: dict[str, dict[str, dict]],
) -> None:
    for ticker, day_map in attention_features.items():
        ticker_enrichment = enrichment.setdefault(ticker, {})
        for day, features in day_map.items():
            ticker_day = ticker_enrichment.setdefault(day, {})
            ticker_day.update(features)


def _orb_candidate_event_tickers(
    candidates: dict[str, list[str]],
    enrichment: dict[str, dict[str, dict]],
    params: ORBStrategyParams,
) -> list[str]:
    """Reduce catalyst fetches to the most relevant daily stocks-in-play names."""
    selected: set[str] = set()
    min_abs_gap = getattr(params, "min_abs_gap_pct", None)
    per_day_limit = max(int(getattr(params, "max_candidates", 20) * 2), 12)
    for day, day_tickers in candidates.items():
        ranked: list[tuple[float, str]] = []
        for ticker in day_tickers:
            ticker_day = enrichment.get(ticker, {}).get(day, {})
            prev_close = ticker_day.get("prev_close")
            today_open = ticker_day.get("today_open")
            if prev_close and today_open and prev_close > 0:
                abs_gap = abs((today_open - prev_close) / prev_close)
                if min_abs_gap is not None and abs_gap < min_abs_gap:
                    continue
                ranked.append((abs_gap, ticker))
        ranked.sort(reverse=True)
        for _, ticker in ranked[:per_day_limit]:
            selected.add(ticker)
    return sorted(selected)


def _orb_candidate_event_pairs(
    candidates: dict[str, list[str]],
    enrichment: dict[str, dict[str, dict]],
    params: ORBStrategyParams,
) -> list[tuple[str, str]]:
    selected: list[tuple[str, str]] = []
    min_abs_gap = getattr(params, "min_abs_gap_pct", None)
    per_day_limit = max(int(getattr(params, "max_candidates", 20) * 2), 12)
    for day, day_tickers in candidates.items():
        ranked: list[tuple[float, str]] = []
        for ticker in day_tickers:
            ticker_day = enrichment.get(ticker, {}).get(day, {})
            prev_close = ticker_day.get("prev_close")
            today_open = ticker_day.get("today_open")
            if prev_close and today_open and prev_close > 0:
                abs_gap = abs((today_open - prev_close) / prev_close)
                if min_abs_gap is not None and abs_gap < min_abs_gap:
                    continue
                ranked.append((abs_gap, ticker))
        ranked.sort(reverse=True)
        for _, ticker in ranked[:per_day_limit]:
            selected.append((ticker, day))
    return selected


def _momentum_strategy_uses_daily_enrichment(params: StrategyParams) -> bool:
    return any(
        value is not None and value != default
        for value, default in [
            (params.min_gap_pct, None),
            (params.max_gap_pct, None),
            (params.min_volume_ratio_14d, None),
            (params.min_ret_5d, None),
            (params.min_entropy_20d, None),
            (params.max_entropy_20d, None),
            (params.entropy_size_scale_low, None),
            (params.entropy_size_scale_high, None),
            (params.use_moderate_gap_liquid_sleeve, False),
            (params.use_liquid_cluster_engine, False),
            (params.use_sector_etf_sleeve, False),
            (params.liquid_cluster_require_special_liquidity_gate, False),
            (params.liquid_cluster_min_avg_dollar_vol_30d, None),
            (params.liquid_cluster_max_avg_dollar_vol_30d, None),
            (params.liquid_cluster_min_volume_ratio_14d, None),
            (params.liquid_cluster_max_entropy_20d, None),
            (params.candidate_seed_moderate_liquid_overlay_slots, 0),
            (params.candidate_intraday_moderate_liquid_reserve_slots, 0),
            (params.market_regime_gap_threshold, None),
            (params.regime_size_scale_low, None),
            (params.regime_size_scale_high, None),
            (params.regime_skip_below, None),
            (params.min_candidate_breadth, None),
            (params.breadth_size_scale_low, None),
            (params.breadth_size_scale_high, None),
            (params.breadth_skip_below, None),
        ]
    ) or params.use_five_sleeves


def _momentum_strategy_uses_sector_labels(params: StrategyParams) -> bool:
    return bool(
        params.max_positions_per_sector
        or params.use_sector_thrust_sleeve
        or params.use_liquid_cluster_engine
        or params.use_sector_etf_sleeve
    )


def _momentum_strategy_uses_sector_proxies(params: StrategyParams) -> bool:
    return bool(
        params.use_sector_etf_sleeve
        and params.sector_etf_capital_fraction > 0
        and params.sector_etf_max_positions > 0
    )


def _momentum_strategy_requires_regime_ticker_daily(params: StrategyParams) -> bool:
    return any(
        value is not None and value != default
        for value, default in [
            (params.market_regime_gap_threshold, None),
            (params.regime_size_scale_low, None),
            (params.regime_size_scale_high, None),
            (params.regime_skip_below, None),
        ]
    )


def _momentum_strategy_uses_catalyst(params: StrategyParams) -> bool:
    return (
        params.candidate_require_event_flag
        or params.candidate_min_event_score is not None
        or bool(params.candidate_allowed_event_types)
        or params.candidate_seed_event_overlay_slots > 0
        or params.candidate_seed_event_min_score is not None
        or params.candidate_weight_event_score > 0
        or params.candidate_intraday_weight_event_score > 0
        or params.candidate_intraday_event_reserve_slots > 0
        or params.candidate_intraday_event_reserve_min_score is not None
        or params.use_event_sleeve
        or params.event_weight > 0
        or params.event_min_score is not None
        or params.use_event_day_liquid_sleeve
        or params.event_day_liquid_min_event_score is not None
        or params.event_day_liquid_min_event_support_score is not None
        or params.event_day_liquid_min_total_event_entry_dollar_volume is not None
    )


def _momentum_strategy_uses_candidate_stage_catalyst(params: StrategyParams) -> bool:
    return (
        params.candidate_require_event_flag
        or params.candidate_min_event_score is not None
        or bool(params.candidate_allowed_event_types)
        or params.candidate_seed_event_overlay_slots > 0
        or params.candidate_seed_event_min_score is not None
    )


def _momentum_candidate_allowed_event_types(strategy: StrategyParams) -> set[str]:
    return {
        str(value).strip().lower()
        for value in strategy.candidate_allowed_event_types
        if str(value).strip()
    }


def _momentum_candidate_event_types_pass(info: dict, strategy: StrategyParams) -> bool:
    allowed_event_types = _momentum_candidate_allowed_event_types(strategy)
    if not allowed_event_types:
        return True
    raw_event_types = info.get("event_types") or []
    event_types = {
        str(value).strip().lower()
        for value in raw_event_types
        if str(value).strip()
    }
    if not event_types:
        return False
    return any(event_type in allowed_event_types for event_type in event_types)


def _momentum_strategy_uses_attention(params: StrategyParams) -> bool:
    return (
        params.candidate_weight_attention_wiki > 0
        or params.candidate_weight_attention_news > 0
        or params.candidate_intraday_weight_attention_wiki > 0
        or params.candidate_intraday_weight_attention_news > 0
        or params.candidate_min_attention_wiki_spike_10d is not None
        or params.candidate_min_attention_article_count_3d is not None
        or params.candidate_min_attention_us_article_count_3d is not None
        or params.candidate_min_attention_resolver_confidence is not None
    )


def _momentum_strategy_uses_vix(params: StrategyParams) -> bool:
    return any(
        value is not None and value != default
        for value, default in [
            (params.max_vix, None),
            (params.vix_size_scale_low, None),
            (params.vix_size_scale_high, None),
        ]
    ) or params.vix_size_scale_min != 1.0


def _momentum_uses_historical_intraday_first(params: StrategyParams) -> bool:
    return str(getattr(params, "candidate_source_mode", "daily_gap")).lower() == "intraday_first"


def _should_use_recent_live_scan(
    strategy: StrategyParams,
    trading_days: list[str],
) -> bool:
    if strategy.recent_live_scan_days <= 0 or not trading_days:
        return False
    if len(trading_days) > strategy.recent_live_scan_days:
        return False
    latest_safe = _latest_backtest_date()
    end_day = date.fromisoformat(trading_days[-1])
    delta_days = (latest_safe - end_day).days
    return 0 <= delta_days <= strategy.recent_live_scan_days


def _recent_live_scan_universe(strategy: StrategyParams) -> UniverseParams:
    return UniverseParams(
        source="screener",
        market_cap_min=strategy.recent_live_scan_market_cap_min,
        avg_volume_min=strategy.recent_live_scan_avg_volume_min,
        min_price=strategy.recent_live_scan_min_price,
    )


def _strategy_for_recent_live_scan(
    strategy: StrategyParams,
    *,
    recent_live_scan: bool,
) -> StrategyParams:
    """Apply recent-window-only overrides without affecting research/Q1 configs."""
    if not recent_live_scan:
        return strategy
    updates: dict[str, object] = {}
    override_fields = [
        ("recent_live_scan_top_n", "top_n"),
        ("recent_live_scan_min_morning_gain_pct", "min_morning_gain_pct"),
        ("recent_live_scan_max_morning_gain_pct", "max_morning_gain_pct"),
        ("recent_live_scan_min_confirmation_return_pct", "min_confirmation_return_pct"),
        ("recent_live_scan_min_entry_dollar_volume", "min_entry_dollar_volume"),
        ("recent_live_scan_max_gap_pct", "max_gap_pct"),
        ("recent_live_scan_max_entropy_20d", "max_entropy_20d"),
        ("recent_live_scan_use_slow_ignite_sleeve", "use_slow_ignite_sleeve"),
        ("recent_live_scan_slow_ignite_weight", "slow_ignite_weight"),
        ("recent_live_scan_slow_ignite_min_gain_pct", "slow_ignite_min_gain_pct"),
        ("recent_live_scan_slow_ignite_max_gain_pct", "slow_ignite_max_gain_pct"),
        ("recent_live_scan_slow_ignite_min_entry_dollar_volume", "slow_ignite_min_entry_dollar_volume"),
        ("recent_live_scan_slow_ignite_max_entropy_20d", "slow_ignite_max_entropy_20d"),
        ("recent_live_scan_use_liquid_largecap_sleeve", "use_liquid_largecap_sleeve"),
        ("recent_live_scan_liquid_largecap_weight", "liquid_largecap_weight"),
        ("recent_live_scan_liquid_largecap_min_gain_pct", "liquid_largecap_min_gain_pct"),
        ("recent_live_scan_liquid_largecap_max_gain_pct", "liquid_largecap_max_gain_pct"),
        ("recent_live_scan_liquid_largecap_min_confirmation_return_pct", "liquid_largecap_min_confirmation_return_pct"),
        ("recent_live_scan_liquid_largecap_min_entry_dollar_volume", "liquid_largecap_min_entry_dollar_volume"),
        ("recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d", "liquid_largecap_min_avg_dollar_vol_30d"),
        ("recent_live_scan_liquid_largecap_max_entropy_20d", "liquid_largecap_max_entropy_20d"),
    ]
    for source_field, target_field in override_fields:
        value = getattr(strategy, source_field, None)
        if value is not None:
            updates[target_field] = value
    if not updates:
        return strategy
    return strategy.model_copy(update=updates)


def _should_use_recent_intraday_first_scan(trading_days: list[str]) -> bool:
    """Recent live-scan windows should use intraday-first candidate generation.

    Using daily-first on 1-5 day sanity windows misses obvious leaders such as
    recent Yahoo gainers that are absent from the static research universe or
    do not pass a daily pre-screen despite strong same-day intraday momentum.
    """
    return bool(trading_days)


def _merge_momentum_event_features(
    enrichment: dict[str, dict[str, dict]],
    event_features: dict[str, dict[str, dict]],
) -> None:
    for ticker, day_map in event_features.items():
        ticker_enrichment = enrichment.setdefault(ticker, {})
        for day, features in day_map.items():
            ticker_day = ticker_enrichment.setdefault(day, {})
            ticker_day.update(features)


def _merge_momentum_attention_features(
    enrichment: dict[str, dict[str, dict]],
    attention_features: dict[str, dict[str, dict]],
) -> None:
    for ticker, day_map in attention_features.items():
        ticker_enrichment = enrichment.setdefault(ticker, {})
        for day, features in day_map.items():
            ticker_day = ticker_enrichment.setdefault(day, {})
            ticker_day.update(features)


def _momentum_preliminary_candidates(
    daily_bars: dict[str, list[dict]],
    trading_days: list[str],
    enrichment: dict[str, dict[str, dict]],
    threshold: float,
    strategy: StrategyParams,
) -> dict[str, list[str]]:
    return momentum_pre_screen_candidates(
        daily_bars,
        trading_days,
        enrichment,
        threshold=threshold,
        max_per_day=None,
        strategy=None,
    )


def _momentum_intraday_seed_candidates(
    daily_bars: dict[str, list[dict]],
    trading_days: list[str],
    enrichment: dict[str, dict[str, dict]],
    strategy: StrategyParams,
    *,
    default_threshold: float,
    use_signal_features: bool = False,
) -> dict[str, list[str]]:
    seed_threshold = strategy.candidate_seed_threshold
    seed_max_per_day = strategy.candidate_seed_max_per_day
    if not _momentum_uses_historical_intraday_first(strategy):
        seed_threshold = default_threshold
        seed_max_per_day = max(strategy.top_n * 20, 120)
    return momentum_pre_screen_candidates(
        daily_bars,
        trading_days,
        enrichment,
        threshold=seed_threshold,
        max_per_day=seed_max_per_day,
        strategy=strategy if use_signal_features else None,
    )


def _augment_momentum_seed_candidates_with_liquid_overlay(
    candidates: dict[str, list[str]],
    daily_bars: dict[str, list[dict]],
    trading_days: list[str],
    enrichment: dict[str, dict[str, dict]],
    strategy: StrategyParams,
) -> tuple[dict[str, list[str]], dict[str, set[str]]]:
    """Returns (augmented_candidates, overlay_tickers_per_day).

    overlay_tickers_per_day maps each date to the set of tickers added by the overlay
    (leader + liquid + event). Used by the ORB path to bypass gapper-specific filters
    for these tickers.
    """
    slots = int(getattr(strategy, "candidate_seed_liquid_overlay_slots", 0) or 0)
    leader_slots = int(getattr(strategy, "candidate_seed_leader_overlay_slots", 0) or 0)
    moderate_slots = int(getattr(strategy, "candidate_seed_moderate_liquid_overlay_slots", 0) or 0)
    event_slots = int(getattr(strategy, "candidate_seed_event_overlay_slots", 0) or 0)
    if slots <= 0 and leader_slots <= 0 and moderate_slots <= 0 and event_slots <= 0:
        return {day: list(day_tickers) for day, day_tickers in candidates.items() if day_tickers}, {}

    ticker_day_bar: dict[str, dict[str, dict]] = {}
    for ticker, bars in daily_bars.items():
        day_map: dict[str, dict] = {}
        for bar in bars:
            day_map[str(bar["date"])[:10]] = bar
        ticker_day_bar[ticker] = day_map

    result: dict[str, list[str]] = {}
    overlay_by_day: dict[str, set[str]] = {}
    min_gap = getattr(strategy, "candidate_seed_liquid_min_gap_pct", None)
    max_gap = getattr(strategy, "candidate_seed_liquid_max_gap_pct", None)
    min_avg_dollar_vol = getattr(strategy, "candidate_seed_liquid_min_avg_dollar_vol_30d", None)
    min_ret_5d = getattr(strategy, "candidate_seed_liquid_min_ret_5d", None)
    max_entropy = getattr(strategy, "candidate_seed_liquid_max_entropy_20d", None)
    leader_min_gap = getattr(strategy, "candidate_seed_leader_min_gap_pct", None)
    leader_max_gap = getattr(strategy, "candidate_seed_leader_max_gap_pct", None)
    leader_min_avg_dollar_vol = getattr(strategy, "candidate_seed_leader_min_avg_dollar_vol_30d", None)
    leader_min_ret_5d = getattr(strategy, "candidate_seed_leader_min_ret_5d", None)
    leader_min_atr_pct = getattr(strategy, "candidate_seed_leader_min_atr_pct", None)
    leader_max_entropy = getattr(strategy, "candidate_seed_leader_max_entropy_20d", None)
    moderate_min_gap = getattr(strategy, "candidate_seed_moderate_liquid_min_gap_pct", None)
    moderate_max_gap = getattr(strategy, "candidate_seed_moderate_liquid_max_gap_pct", None)
    moderate_min_avg_dollar_vol = getattr(
        strategy,
        "candidate_seed_moderate_liquid_min_avg_dollar_vol_30d",
        None,
    )
    moderate_max_avg_dollar_vol = getattr(
        strategy,
        "candidate_seed_moderate_liquid_max_avg_dollar_vol_30d",
        None,
    )
    moderate_min_ret_5d = getattr(strategy, "candidate_seed_moderate_liquid_min_ret_5d", None)
    moderate_max_entropy = getattr(strategy, "candidate_seed_moderate_liquid_max_entropy_20d", None)
    event_min_score = getattr(strategy, "candidate_seed_event_min_score", None)
    event_min_gap = getattr(strategy, "candidate_seed_event_min_gap_pct", None)
    event_max_gap = getattr(strategy, "candidate_seed_event_max_gap_pct", None)
    event_min_avg_dollar_vol = getattr(strategy, "candidate_seed_event_min_avg_dollar_vol_30d", None)
    event_min_ret_5d = getattr(strategy, "candidate_seed_event_min_ret_5d", None)
    event_max_entropy = getattr(strategy, "candidate_seed_event_max_entropy_20d", None)

    for day in trading_days:
        day_candidates = list(candidates.get(day, []))
        chosen = set(day_candidates)
        day_overlay_tickers: set[str] = set()
        overlay_ranked: list[tuple[float, float, float, str]] = []
        for ticker, date_map in ticker_day_bar.items():
            if ticker in chosen:
                continue
            if day not in date_map:
                continue
            info = enrichment.get(ticker, {}).get(day, {})
            gap_pct = info.get("gap_pct")
            if gap_pct is None:
                continue
            if min_gap is not None and gap_pct < min_gap:
                continue
            if max_gap is not None and gap_pct > max_gap:
                continue
            avg_dollar_vol = info.get("avg_dollar_vol_30d")
            if min_avg_dollar_vol is not None and (
                avg_dollar_vol is None or avg_dollar_vol < min_avg_dollar_vol
            ):
                continue
            ret_5d = info.get("ret_5d")
            if min_ret_5d is not None and (ret_5d is None or ret_5d < min_ret_5d):
                continue
            entropy_20d = info.get("entropy_20d")
            if max_entropy is not None and (
                entropy_20d is None or entropy_20d > max_entropy
            ):
                continue
            overlay_ranked.append(
                (
                    float(avg_dollar_vol or 0.0),
                    float(gap_pct or 0.0),
                    float(ret_5d or 0.0),
                    ticker,
                )
            )
        if overlay_ranked:
            ranked_overlay = [ticker for *_rest, ticker in sorted(overlay_ranked, reverse=True)[:slots]]
            for ticker in ranked_overlay:
                if ticker not in chosen:
                    day_candidates.append(ticker)
                    chosen.add(ticker)
                    day_overlay_tickers.add(ticker)
        moderate_ranked: list[tuple[float, float, float, float, str]] = []
        for ticker, date_map in ticker_day_bar.items():
            if ticker in chosen:
                continue
            if day not in date_map:
                continue
            info = enrichment.get(ticker, {}).get(day, {})
            gap_pct = info.get("gap_pct")
            if moderate_min_gap is not None and (gap_pct is None or gap_pct < moderate_min_gap):
                continue
            if moderate_max_gap is not None and (gap_pct is None or gap_pct > moderate_max_gap):
                continue
            avg_dollar_vol = info.get("avg_dollar_vol_30d")
            if moderate_min_avg_dollar_vol is not None and (
                avg_dollar_vol is None or avg_dollar_vol < moderate_min_avg_dollar_vol
            ):
                continue
            if moderate_max_avg_dollar_vol is not None and (
                avg_dollar_vol is None or avg_dollar_vol > moderate_max_avg_dollar_vol
            ):
                continue
            ret_5d = info.get("ret_5d")
            if moderate_min_ret_5d is not None and (ret_5d is None or ret_5d < moderate_min_ret_5d):
                continue
            entropy_20d = info.get("entropy_20d")
            if moderate_max_entropy is not None and (
                entropy_20d is None or entropy_20d > moderate_max_entropy
            ):
                continue
            moderate_ranked.append(
                (
                    float(gap_pct or 0.0),
                    float(avg_dollar_vol or 0.0),
                    float(ret_5d or 0.0),
                    -float(entropy_20d if entropy_20d is not None else 1.0),
                    ticker,
                )
            )
        if moderate_ranked:
            ranked_moderate = [
                ticker
                for *_rest, ticker in sorted(moderate_ranked, reverse=True)[:moderate_slots]
            ]
            for ticker in ranked_moderate:
                if ticker not in chosen:
                    day_candidates.append(ticker)
                    chosen.add(ticker)
                    day_overlay_tickers.add(ticker)
                    enrichment.setdefault(ticker, {}).setdefault(day, {})[
                        "candidate_seed_moderate_liquid_overlay"
                    ] = True
        event_ranked: list[tuple[float, float, float, float, float, str]] = []
        for ticker, date_map in ticker_day_bar.items():
            if ticker in chosen:
                continue
            if day not in date_map:
                continue
            info = enrichment.get(ticker, {}).get(day, {})
            if not bool(info.get("event_flag")):
                continue
            if not _momentum_candidate_event_types_pass(info, strategy):
                continue
            event_score = float(info.get("event_score") or 0.0)
            if event_min_score is not None and event_score < float(event_min_score):
                continue
            gap_pct = info.get("gap_pct")
            if event_min_gap is not None and (gap_pct is None or gap_pct < event_min_gap):
                continue
            if event_max_gap is not None and (gap_pct is None or gap_pct > event_max_gap):
                continue
            avg_dollar_vol = info.get("avg_dollar_vol_30d")
            if event_min_avg_dollar_vol is not None and (
                avg_dollar_vol is None or avg_dollar_vol < event_min_avg_dollar_vol
            ):
                continue
            ret_5d = info.get("ret_5d")
            if event_min_ret_5d is not None and (ret_5d is None or ret_5d < event_min_ret_5d):
                continue
            entropy_20d = info.get("entropy_20d")
            if event_max_entropy is not None and (
                entropy_20d is None or entropy_20d > event_max_entropy
            ):
                continue
            event_ranked.append(
                (
                    event_score,
                    float(avg_dollar_vol or 0.0),
                    float(gap_pct or 0.0),
                    float(ret_5d or 0.0),
                    -float(entropy_20d if entropy_20d is not None else 1.0),
                    ticker,
                )
            )
        if event_ranked:
            ranked_events = [
                ticker for *_rest, ticker in sorted(event_ranked, reverse=True)[:event_slots]
            ]
            for ticker in ranked_events:
                if ticker not in chosen:
                    day_candidates.append(ticker)
                    chosen.add(ticker)
                    day_overlay_tickers.add(ticker)
        leader_ranked: list[tuple[float, float, float, float, str]] = []
        for ticker, date_map in ticker_day_bar.items():
            if ticker in chosen:
                continue
            bar = date_map.get(day)
            if not bar:
                continue
            info = enrichment.get(ticker, {}).get(day, {})
            gap_pct = info.get("gap_pct")
            if leader_min_gap is not None and (gap_pct is None or gap_pct < leader_min_gap):
                continue
            if leader_max_gap is not None and (gap_pct is None or gap_pct > leader_max_gap):
                continue
            avg_dollar_vol = info.get("avg_dollar_vol_30d")
            if leader_min_avg_dollar_vol is not None and (
                avg_dollar_vol is None or avg_dollar_vol < leader_min_avg_dollar_vol
            ):
                continue
            ret_5d = info.get("ret_5d")
            if leader_min_ret_5d is not None and (ret_5d is None or ret_5d < leader_min_ret_5d):
                continue
            entropy_20d = info.get("entropy_20d")
            if leader_max_entropy is not None and (
                entropy_20d is None or entropy_20d > leader_max_entropy
            ):
                continue
            atr_14 = info.get("atr_14")
            open_price = bar.get("open")
            atr_pct = (
                float(atr_14) / float(open_price)
                if atr_14 is not None and open_price not in (None, 0)
                else None
            )
            if leader_min_atr_pct is not None and (atr_pct is None or atr_pct < leader_min_atr_pct):
                continue
            leader_ranked.append(
                (
                    float(ret_5d or 0.0),
                    float(avg_dollar_vol or 0.0),
                    float(atr_pct or 0.0),
                    -float(entropy_20d or 1.0),
                    ticker,
                )
            )
        if leader_ranked:
            ranked_leaders = [
                ticker for *_rest, ticker in sorted(leader_ranked, reverse=True)[:leader_slots]
            ]
            for ticker in ranked_leaders:
                if ticker not in chosen:
                    day_candidates.append(ticker)
                    chosen.add(ticker)
                    day_overlay_tickers.add(ticker)
        if day_candidates:
            result[day] = day_candidates
            if day_overlay_tickers:
                overlay_by_day[day] = day_overlay_tickers
    return result, overlay_by_day


def _normalize_candidate_map(
    candidates: dict[str, list[str]] | tuple[dict[str, list[str]], object],
) -> dict[str, list[str]]:
    """Normalize helper output to a plain {day: tickers} map.

    Some call sites work with helpers that return `(candidate_map, metadata)`.
    Normalizing here keeps run/sweep code robust and avoids shape drift across paths.
    """
    if isinstance(candidates, tuple):
        candidates = candidates[0]
    return {
        day: list(day_tickers)
        for day, day_tickers in candidates.items()
        if day_tickers
    }


def _momentum_candidate_event_tickers(
    candidates: dict[str, list[str]],
) -> list[str]:
    selected: set[str] = set()
    for day_tickers in candidates.values():
        selected.update(day_tickers)
    return sorted(selected)


def _momentum_candidate_event_pairs(
    candidates: dict[str, list[str]],
    enrichment: dict[str, dict[str, dict]],
    strategy: StrategyParams,
) -> list[tuple[str, str]]:
    per_day_limit = max(strategy.top_n * 15, 60)
    selected: list[tuple[str, str]] = []
    for day, day_tickers in candidates.items():
        ranked = sorted(
            day_tickers,
            key=lambda ticker: (
                float(enrichment.get(ticker, {}).get(day, {}).get("gap_pct") or 0.0),
                float(enrichment.get(ticker, {}).get(day, {}).get("ret_5d") or 0.0),
                -float(enrichment.get(ticker, {}).get(day, {}).get("entropy_20d") or 1.0),
                float(enrichment.get(ticker, {}).get(day, {}).get("avg_dollar_vol_30d") or 0.0),
            ),
            reverse=True,
        )
        for ticker in ranked[:per_day_limit]:
            selected.append((ticker, day))
    return selected


def _recent_intraday_first_candidates(
    all_intraday: dict[str, dict[str, list[dict]]],
    trading_days: list[str],
    strategy: StrategyParams,
) -> dict[str, list[str]]:
    """Build a recent-day candidate shortlist directly from intraday action.

    This path is only used for very recent sanity windows where a static universe and
    daily-first pre-screen can miss obvious day leaders (for example names that are
    absent from the research universe but present in today's Yahoo gainers list).
    """
    result: dict[str, list[str]] = {}
    shortlist_size = max(strategy.top_n * 25, strategy.recent_live_scan_max_candidates_per_day)
    volume_gate = max(100_000, int((strategy.min_entry_volume or 0) * 0.5))
    liquid_dollar_gate = max(
        50_000_000.0,
        float(strategy.min_entry_dollar_volume or 0.0) * 10.0,
    )
    largecap_dollar_gate = max(liquid_dollar_gate, 100_000_000.0)

    for day in trading_days:
        bars_by_ticker = all_intraday.get(day, {})
        if not bars_by_ticker:
            continue
        market_open = _market_open_ts(day)
        fast_scored: list[tuple[float, float, str]] = []
        largecap_scored: list[tuple[float, float, float, str]] = []
        liquid_scored: list[tuple[float, float, float, str]] = []
        slow_scored: list[tuple[float, float, float, str]] = []
        for ticker, all_bars in bars_by_ticker.items():
            mkt_bars = filter_market_hours(all_bars)
            if len(mkt_bars) < 5:
                continue
            open_price = float(mkt_bars[0].get("open", 0.0) or 0.0)
            if open_price <= 0:
                continue
            entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open)
            if entry_bar is None:
                continue
            entry_price = float(entry_bar.get("close", 0.0) or 0.0)
            if entry_price <= 0:
                continue
            gain_pct = (entry_price - open_price) / open_price
            confirmation_bar = entry_bar
            if strategy.confirmation_minutes_after_entry > 0:
                confirmation_bar = _bar_at_offset(
                    mkt_bars,
                    market_open,
                    strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry,
                )
                if confirmation_bar is None:
                    continue
            confirmation_price = float(confirmation_bar.get("close", 0.0) or 0.0)
            if confirmation_price <= 0:
                continue
            confirmation_gain_pct = (confirmation_price - open_price) / open_price
            confirmation_return = (confirmation_price - entry_price) / entry_price if entry_price > 0 else 0.0
            entry_ts = _parse_et_bar_timestamp(confirmation_bar["timestamp"])
            entry_volume = _volume_up_to_bar(mkt_bars, entry_ts)
            if entry_volume < volume_gate:
                continue
            entry_dollar_volume = _dollar_volume_up_to_bar(mkt_bars, entry_ts)
            if gain_pct > 0:
                fast_scored.append((gain_pct, entry_volume, ticker))
            if (
                confirmation_gain_pct >= 0.002
                and confirmation_return >= -0.001
                and entry_dollar_volume >= largecap_dollar_gate
            ):
                largecap_scored.append((entry_dollar_volume, confirmation_return, confirmation_gain_pct, ticker))
            if (
                confirmation_gain_pct >= 0.003
                and confirmation_return >= 0.0
                and entry_dollar_volume >= liquid_dollar_gate
            ):
                liquid_scored.append((entry_dollar_volume, confirmation_gain_pct, confirmation_return, ticker))
            if (
                confirmation_gain_pct >= 0.003
                and confirmation_gain_pct < max(strategy.min_morning_gain_pct, 0.015)
                and confirmation_return >= 0.0
                and entry_dollar_volume >= liquid_dollar_gate
            ):
                slow_scored.append((confirmation_return, entry_dollar_volume, confirmation_gain_pct, ticker))
        if not fast_scored and not largecap_scored and not liquid_scored and not slow_scored:
            continue
        fast_slots = max(strategy.top_n * 8, int(shortlist_size * 0.45))
        largecap_slots = max(strategy.top_n * 4, int(shortlist_size * 0.20))
        liquid_slots = max(strategy.top_n * 4, int(shortlist_size * 0.20))
        slow_slots = max(strategy.top_n * 2, shortlist_size - fast_slots - largecap_slots - liquid_slots)
        ranked_fast = [ticker for _gain, _vol, ticker in sorted(fast_scored, reverse=True)[:fast_slots]]
        ranked_largecap = [
            ticker for _dvol, _conf_ret, _gain, ticker in sorted(largecap_scored, reverse=True)[:largecap_slots]
        ]
        ranked_liquid = [
            ticker for _dvol, _gain, _conf, ticker in sorted(liquid_scored, reverse=True)[:liquid_slots]
        ]
        ranked_slow = [
            ticker for _conf, _dvol, _gain, ticker in sorted(slow_scored, reverse=True)[:slow_slots]
        ]
        merged: list[str] = []
        for source in (ranked_fast, ranked_largecap, ranked_liquid, ranked_slow):
            for ticker in source:
                if ticker not in merged:
                    merged.append(ticker)
                if len(merged) >= shortlist_size:
                    break
            if len(merged) >= shortlist_size:
                break
        if merged:
            result[day] = merged
    return result


def _retain_recent_intraday_shortlist(
    candidates: dict[str, list[str]],
    daily_bars: dict[str, list[dict]],
    *,
    require_daily_features: bool,
) -> dict[str, list[str]]:
    """Keep the intraday-first shortlist instead of overwriting it with daily pre-screen.

    When momentum strategies need daily enrichment, recent same-day leaders still need a
    cached daily history row for gap/entropy/trend features. Otherwise retain the
    intraday-first shortlist as-is.
    """
    if not require_daily_features:
        return {day: list(day_tickers) for day, day_tickers in candidates.items() if day_tickers}
    retained: dict[str, list[str]] = {}
    for day, day_tickers in candidates.items():
        kept = [ticker for ticker in day_tickers if ticker in daily_bars]
        if kept:
            retained[day] = kept
    return retained


def _parse_et_bar_timestamp(timestamp: str):
    from libs.intraday.simulator import _parse_ts

    return _parse_ts(timestamp)


async def _fetch_vix_by_day(
    client: OracleClient,
    trading_days: list[str],
) -> dict[str, float]:
    if not trading_days:
        return {}
    try:
        if not await client.health_check_fast():
            return _load_vix_from_local_macro_snapshots(trading_days)
    except Exception:
        return _load_vix_from_local_macro_snapshots(trading_days)
    try:
        fred = FredService(client)
        response = await fred.get_observations("VIXCLS", start=trading_days[0], end=trading_days[-1])
        result: dict[str, float] = {}
        for obs in response.observations:
            if obs.value is None:
                continue
            result[obs.date] = float(obs.value)
        if result:
            return result
    except Exception:
        pass
    return _load_vix_from_local_macro_snapshots(trading_days)


_MACRO_WINDOW_RE = re.compile(r"macro_window_(\d{4}-\d{2}-\d{2})_(\d{4}-\d{2}-\d{2})\.pkl$")


def _load_vix_from_local_macro_snapshots(trading_days: list[str]) -> dict[str, float]:
    """Best-effort local fallback when Oracle/FRED is unavailable.

    Several backtester workflows persist macro_window_*.pkl files with point-in-time
    VIX values. Reusing them keeps intraday research and official backtests from
    hard-failing when the FRED proxy is temporarily down.
    """
    if not trading_days:
        return {}
    settings = get_settings()
    root = Path(settings.data_root) / "parquet"
    if not root.exists():
        return {}

    start = trading_days[0]
    end = trading_days[-1]
    best_path: Path | None = None
    best_span: int | None = None
    for path in root.rglob("macro_window_*.pkl"):
        match = _MACRO_WINDOW_RE.search(path.name)
        if not match:
            continue
        window_start, window_end = match.groups()
        if window_start > start or window_end < end:
            continue
        span = (date.fromisoformat(window_end) - date.fromisoformat(window_start)).days
        if best_span is None or span < best_span:
            best_span = span
            best_path = path

    if best_path is None:
        return {}

    try:
        with best_path.open("rb") as fh:
            payload = pickle.load(fh)
    except Exception:
        return {}
    if not isinstance(payload, dict):
        return {}

    result: dict[str, float] = {}
    for key, values in payload.items():
        if not isinstance(values, dict):
            continue
        vix_value = values.get("VIXCLS")
        if vix_value is None:
            continue
        if hasattr(key, "isoformat"):
            key_str = key.isoformat()
        else:
            key_str = str(key)
        if start <= key_str <= end:
            result[key_str] = float(vix_value)
    return result


def _momentum_enrichment_for_days(
    daily_bars: dict[str, list[dict]],
    trading_days: list[str],
) -> dict[str, dict[str, dict]]:
    enrichment = enrich_daily_bars(daily_bars, trading_days)
    for ticker, day_map in enrichment.items():
        ticker_bars = sorted(daily_bars.get(ticker, []), key=lambda bar: bar["date"])
        by_day = {bar["date"][:10]: bar for bar in ticker_bars}
        for day, features in day_map.items():
            prev_close = features.get("prev_close")
            today_open = features.get("today_open")
            features["gap_pct"] = (
                compute_gap_pct(prev_close, today_open)
                if prev_close is not None and today_open is not None
                else None
            )
            today_bar = by_day.get(day)
            if today_bar and today_bar.get("volume") and features.get("avg_daily_vol_14d"):
                avg_daily_vol = features["avg_daily_vol_14d"]
                features["daily_volume_ratio_14d"] = (
                    today_bar["volume"] / avg_daily_vol if avg_daily_vol and avg_daily_vol > 0 else None
                )
            else:
                features["daily_volume_ratio_14d"] = None
    return enrichment


# ── Progress Reporting ─────────────────────────────────────────────────────


def _make_progress_bar(completed: int, total: int, width: int = 30) -> str:
    pct = completed / total if total > 0 else 0
    filled = int(width * pct)
    bar = "█" * filled + "░" * (width - filled)
    return f"[{bar}] {completed}/{total} ({pct*100:.0f}%)"


def _chunk_trading_days_by_pairs(
    trading_days: list[str],
    candidates: dict[str, list[str]],
    max_pairs_per_chunk: int = 5_000,
) -> list[list[str]]:
    """Split trading days into contiguous chunks capped by candidate pair count.

    ORB single-run backtests previously loaded every candidate day's 5-minute bars
    into memory before simulation. Large windows can exceed multiple GB in Python
    objects, so we stream a few days at a time instead.
    """
    chunks: list[list[str]] = []
    current: list[str] = []
    current_pairs = 0

    for day in trading_days:
        day_pairs = len(candidates.get(day, []))
        if current and current_pairs + day_pairs > max_pairs_per_chunk:
            chunks.append(current)
            current = []
            current_pairs = 0
        current.append(day)
        current_pairs += day_pairs

    if current:
        chunks.append(current)

    return chunks


# ── Main Orchestrator ──────────────────────────────────────────────────────


async def run(config: IntradayConfig, refresh_cache: bool = False) -> tuple:
    """Full pipeline: universe → daily bars → intraday bars → simulate → metrics.

    Returns (day_results, metrics, all_intraday, trading_days).
    """
    settings = get_settings()

    is_orb = config.strategy_mode == "orb"

    if is_orb:
        p = config.orb_strategy or ORBStrategyParams()
        timeout_minutes = 9 * 60 + 30 + p.order_timeout_minutes
        timeout_hour, timeout_minute = divmod(timeout_minutes, 60)
        print(
            f"\nORB | {config.universe.source} | ${p.initial_capital:,.0f} | "
            f"last {config.backtest.lookback_trading_days}d | "
            f"orb:{p.orb_minutes}min stop:{p.atr_stop_multiplier}xATR "
            f"be:{p.breakeven_at_r}R tr:{p.trailing_at_r}R timeout:{timeout_hour:02d}:{timeout_minute:02d} "
            f"rvol:{p.min_rvol} risk:{p.risk_per_trade_pct*100:.2f}%"
        )
    else:
        s = config.strategy
        print(
            f"\nMoMo | {config.universe.source} | ${s.initial_capital:,.0f} | "
            f"last {config.backtest.lookback_trading_days}d | "
            f"entry:+{s.entry_minutes_after_open}min exit:-{s.exit_minutes_before_close}min "
            f"stop:{s.stop_loss_pct or 'off'} gain:{s.min_morning_gain_pct*100:.1f}% top:{s.top_n}"
        )

    cache = IntradayCache(config.cache.dir) if config.cache.enabled else None
    daily_cache = (
        DailyBarCache(str(Path(config.cache.dir).with_name("daily")))
        if config.cache.enabled else None
    )
    event_cache = (
        FilingEventCache(str(Path(config.cache.dir).with_name("orb_catalyst")))
        if config.cache.enabled else None
    )
    attention_cache = (
        AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention")))
        if config.cache.enabled else None
    )
    attention_cache = (
        AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention")))
        if config.cache.enabled else None
    )
    if refresh_cache and cache:
        print("\n  Refreshing cache (evicting all entries)...")
        removed = cache.evict()
        print(f"  Removed {removed} cached files.")

    if cache:
        stats = cache.stats()
        print(f"\n  Cache: {stats['total_files']} files, {stats['total_mb']} MB")

    async with make_intraday_oracle_client(settings) as client:
        # Step 1: Get trading days
        print("[1/4] Resolving trading calendar...")
        trading_days = await get_trading_days(
            client,
            config.backtest.start_date,
            config.backtest.end_date,
            config.backtest.lookback_trading_days,
        )
        print(f"      {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)")

        # Step 2: Resolve universe
        recent_live_scan = (not is_orb) and _should_use_recent_live_scan(config.strategy, trading_days)
        if not is_orb:
            config = config.model_copy(
                update={"strategy": _strategy_for_recent_live_scan(config.strategy, recent_live_scan=recent_live_scan)}
            )
        universe_params = _recent_live_scan_universe(config.strategy) if recent_live_scan else config.universe
        universe_label = (
            f"{config.universe.source} + recent-live-scan"
            if recent_live_scan
            else config.universe.source
        )
        recent_intraday_first_scan = recent_live_scan and _should_use_recent_intraday_first_scan(trading_days)
        print(f"[2/4] Resolving universe ({universe_label})...")
        tickers = await resolve_universe(universe_params, client)
        print(f"      {len(tickers)} tickers")

        if recent_intraday_first_scan:
            print(
                f"[3/4] Phase 1: Fetching intraday bars for recent live scan "
                f"({len(tickers)} tickers across {len(trading_days)} days)..."
            )
            intraday_seed = {day: tickers for day in trading_days}
            _live_last_pct = [-1]

            def live_intraday_progress(completed: int, total: int, hits: int, calls: int) -> None:
                if completed == 0 and calls == 0 and total > 0:
                    sys.stdout.write("\n")
                    sys.stdout.flush()
                    _live_last_pct[0] = -1
                pct = int(completed / total * 10) * 10 if total > 0 else 0
                if pct > _live_last_pct[0] or completed == total:
                    _live_last_pct[0] = pct
                    sys.stdout.write(
                        f"\r      {_make_progress_bar(completed, total)}  cache:{hits}  api:{calls}"
                    )
                    sys.stdout.flush()

            all_intraday = await fetch_intraday_bulk(
                intraday_seed,
                client,
                cache,
                concurrency=8,
                progress_callback=live_intraday_progress,
            )
            print()
            candidates = _recent_intraday_first_candidates(all_intraday, trading_days, config.strategy)
            total_pairs = sum(len(v) for v in candidates.values())
            print(
                f"      Intraday-first shortlisted: {total_pairs} ticker-day pairs "
                f"across {len(candidates)} days"
            )
            shortlisted_tickers = sorted({ticker for day in candidates.values() for ticker in day})
            daily_bars: dict[str, list[dict]] = {}
            if shortlisted_tickers and _momentum_strategy_uses_daily_enrichment(config.strategy):
                print(f"      Fetching daily enrichment bars for {len(shortlisted_tickers)} shortlisted tickers...")
                n_done = [0]
                _daily_last_pct = [-1]

                def daily_progress(completed: int, total: int) -> None:
                    n_done[0] = completed
                    pct = int(completed / total * 10) * 10 if total > 0 else 0
                    if pct > _daily_last_pct[0] or completed == total:
                        _daily_last_pct[0] = pct
                        sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                        sys.stdout.flush()

                daily_fetch_start = (
                    date.fromisoformat(trading_days[0]) - timedelta(days=90)
                ).isoformat()
                daily_bars = await fetch_daily_bars_bulk(
                    shortlisted_tickers,
                    daily_fetch_start,
                    trading_days[-1],
                    client,
                    cache=daily_cache,
                    intraday_cache_fallback=cache,
                    prefer_intraday_fallback=True,
                    concurrency=20,
                    progress_callback=daily_progress,
                )
                print(f"\n      {len(daily_bars)}/{len(shortlisted_tickers)} shortlisted tickers with daily data")
        else:
            # Step 3: Phase 1 — Fetch daily bars + pre-screen
            print(f"[3/4] Phase 1: Fetching daily bars for {len(tickers)} tickers...")
        ticker_sectors = (
            _load_orb_ticker_sectors(tickers)
            if is_orb
            else (
                await _load_ticker_sectors_with_oracle(tickers, client)
                if _momentum_strategy_uses_sector_labels(config.strategy)
                else {}
            )
        )
        n_done = [0]
        _daily_last_pct = [-1]

        def daily_progress(completed: int, total: int) -> None:
            n_done[0] = completed
            pct = int(completed / total * 10) * 10 if total > 0 else 0
            if pct > _daily_last_pct[0] or completed == total:
                _daily_last_pct[0] = pct
                sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                sys.stdout.flush()

        # For ORB mode, fetch extra prior history for enrichment (ATR/volume warmup).
        # enrich_daily_bars() uses only bars BEFORE each trading day, so extra bars
        # before trading_days[0] act as warmup and never appear in simulation results.
        # Without this, a short (e.g. single-day) backtest has no prior bars and all
        # candidates get filtered out (ATR/dollar-vol = None → zero trades).
        if not recent_intraday_first_scan:
            if is_orb or _momentum_strategy_uses_daily_enrichment(config.strategy):
                warmup_start = (
                    date.fromisoformat(trading_days[0]) - timedelta(days=90)
                ).isoformat()
                daily_fetch_start = warmup_start
            else:
                daily_fetch_start = trading_days[0]

            daily_bars = await fetch_daily_bars_bulk(
                tickers,
                daily_fetch_start,
                trading_days[-1],
                client,
                cache=daily_cache,
                intraday_cache_fallback=cache,
                prefer_intraday_fallback=True,
                skip_oracle_when_unhealthy=True,
                concurrency=20,
                progress_callback=daily_progress,
            )
            print(f"\n      {len(daily_bars)}/{len(tickers)} tickers with data")

        momentum_enrichment: dict[str, dict[str, dict]] | None = None
        momentum_vix_by_day: dict[str, float] | None = None
        momentum_seed_candidates: dict[str, list[str]] | None = None
        if is_orb:
            # ORB: enrich daily bars, then filter by quality metrics
            orb_params = config.orb_strategy or ORBStrategyParams()
            # Ensure regime ticker is in daily_bars for market regime filter
            regime_ticker = getattr(orb_params, "market_regime_ticker", "SPY") or "SPY"
            if orb_params.market_regime_spy_threshold is not None and regime_ticker not in daily_bars:
                extra_bars = await fetch_daily_bars_bulk(
                    [regime_ticker], daily_fetch_start, trading_days[-1], client,
                    cache=daily_cache, intraday_cache_fallback=cache, concurrency=1
                )
                daily_bars.update(extra_bars)
            print("      Computing ATR/volume enrichment...")
            enrichment = enrich_daily_bars(daily_bars, trading_days)
            candidates = orb_pre_screen_candidates(
                daily_bars,
                trading_days,
                enrichment,
                min_price=orb_params.min_price,
                min_atr=orb_params.min_atr_14,
                min_avg_dollar_vol=orb_params.min_avg_dollar_volume,
                max_per_day=None,
            )
            orb_overlay_tickers_per_day: dict[str, set[str]] | None = None
            if (
                int(getattr(orb_params, "candidate_seed_leader_overlay_slots", 0) or 0) > 0
                or int(getattr(orb_params, "candidate_seed_liquid_overlay_slots", 0) or 0) > 0
            ):
                candidates, orb_overlay_tickers_per_day = _augment_momentum_seed_candidates_with_liquid_overlay(  # type: ignore[arg-type]
                    candidates, daily_bars, trading_days, enrichment, orb_params
                )
            if _orb_strategy_uses_catalyst(orb_params):
                _prior_lookback = int(getattr(orb_params, "prior_event_lookback_days", 0) or 0)
                if _prior_lookback > 0:
                    all_tickers = list({t for day_tickers in candidates.values() for t in day_tickers})
                    _prior_event_types = tuple(getattr(orb_params, "prior_event_types", None) or ["earnings_release", "guidance_update"])
                    print(f"      Prefetching prior-event features from DB (D-{_prior_lookback}, types={_prior_event_types}) 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,
                    )
                    print(f"      Prior-event coverage: {len(event_features)} tickers with events")
                else:
                    event_tickers = _orb_candidate_event_tickers(candidates, enrichment, orb_params)
                    print(f"      Fetching filing catalyst events for {len(event_tickers)} tickers...")
                    _evt_last_pct = [-1]

                    def event_progress(completed: int, total: int) -> None:
                        pct = int(completed / total * 10) * 10 if total > 0 else 0
                        if pct > _evt_last_pct[0] or completed == total:
                            _evt_last_pct[0] = pct
                            sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                            sys.stdout.flush()

                    event_features = await fetch_filing_event_features_bulk(
                        event_tickers,
                        trading_days[0],
                        trading_days[-1],
                        client,
                        cache=event_cache,
                        concurrency=16,
                        progress_callback=event_progress,
                    )
                    print()
                _merge_orb_event_features(enrichment, event_features)

            if _orb_strategy_uses_attention(orb_params):
                attention_pairs = _orb_candidate_event_pairs(candidates, enrichment, orb_params)
                print(f"      Fetching event attention for {len(attention_pairs)} ticker-days...")
                _attn_last_pct = [-1]

                def attention_progress(completed: int, total: int) -> None:
                    pct = int(completed / total * 10) * 10 if total > 0 else 0
                    if pct > _attn_last_pct[0] or completed == total:
                        _attn_last_pct[0] = pct
                        sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                        sys.stdout.flush()

                attention_features = await fetch_attention_features_bulk(
                    attention_pairs,
                    client,
                    cache=attention_cache,
                    concurrency=16,
                    progress_callback=attention_progress,
                )
                print()
                _merge_orb_attention_features(enrichment, attention_features)
            if _orb_strategy_uses_vix(orb_params):
                print("      Fetching VIX regime series for ORB...")
                momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days)
        else:
            enrichment = {}
            if recent_intraday_first_scan:
                candidates = _retain_recent_intraday_shortlist(
                    candidates,
                    daily_bars,
                    require_daily_features=_momentum_strategy_uses_daily_enrichment(config.strategy),
                )
            if _momentum_strategy_requires_regime_ticker_daily(config.strategy):
                regime_ticker = config.strategy.market_regime_gap_ticker or "SPY"
                if regime_ticker not in daily_bars:
                    extra_bars = await fetch_daily_bars_bulk(
                        [regime_ticker],
                        daily_fetch_start,
                        trading_days[-1],
                        client,
                        cache=daily_cache,
                        intraday_cache_fallback=cache,
                        prefer_intraday_fallback=True,
                        skip_oracle_when_unhealthy=True,
                        concurrency=1,
                    )
                    daily_bars.update(extra_bars)
            if _momentum_strategy_uses_daily_enrichment(config.strategy):
                print("      Computing momentum daily enrichment...")
                momentum_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days)
            if _momentum_strategy_uses_vix(config.strategy):
                print("      Fetching VIX regime series...")
                momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days)
            if (
                not recent_intraday_first_scan
                and momentum_enrichment is not None
                and (
                    _momentum_strategy_uses_catalyst(config.strategy)
                    or _momentum_strategy_uses_attention(config.strategy)
                )
            ):
                preliminary_candidates = _normalize_candidate_map(
                    _momentum_intraday_seed_candidates(
                        daily_bars,
                        trading_days,
                        momentum_enrichment,
                        config.strategy,
                        default_threshold=config.backtest.pre_screen_threshold,
                        use_signal_features=False,
                    )
                )
                if _momentum_strategy_uses_catalyst(config.strategy):
                    if _momentum_strategy_uses_candidate_stage_catalyst(config.strategy):
                        event_tickers = sorted(daily_bars.keys())
                    else:
                        event_tickers = _momentum_candidate_event_tickers(preliminary_candidates)
                    print(f"      Fetching momentum filing catalysts for {len(event_tickers)} tickers...")
                    _evt_last_pct = [-1]

                    def event_progress(completed: int, total: int) -> None:
                        pct = int(completed / total * 10) * 10 if total > 0 else 0
                        if pct > _evt_last_pct[0] or completed == total:
                            _evt_last_pct[0] = pct
                            sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                            sys.stdout.flush()

                    event_features = await fetch_filing_event_features_bulk(
                        event_tickers,
                        trading_days[0],
                        trading_days[-1],
                        client,
                        cache=event_cache,
                        concurrency=16,
                        progress_callback=event_progress,
                    )
                    print()
                    _merge_momentum_event_features(momentum_enrichment, event_features)
                if _momentum_strategy_uses_attention(config.strategy):
                    attention_pairs = _momentum_candidate_event_pairs(
                        preliminary_candidates,
                        momentum_enrichment,
                        config.strategy,
                    )
                    print(f"      Fetching momentum attention for {len(attention_pairs)} ticker-days...")
                    _attn_last_pct = [-1]

                    def attention_progress(completed: int, total: int) -> None:
                        pct = int(completed / total * 10) * 10 if total > 0 else 0
                        if pct > _attn_last_pct[0] or completed == total:
                            _attn_last_pct[0] = pct
                            sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                            sys.stdout.flush()

                    attention_features = await fetch_attention_features_bulk(
                        attention_pairs,
                        client,
                        cache=attention_cache,
                        concurrency=16,
                        progress_callback=attention_progress,
                    )
                    print()
                    _merge_momentum_attention_features(momentum_enrichment, attention_features)
            if not recent_intraday_first_scan:
                momentum_seed_candidates = _momentum_intraday_seed_candidates(
                    daily_bars,
                    trading_days,
                    momentum_enrichment or {},
                    config.strategy,
                    default_threshold=config.backtest.pre_screen_threshold,
                    use_signal_features=True,
                )
                momentum_seed_candidates, _ = _augment_momentum_seed_candidates_with_liquid_overlay(
                    momentum_seed_candidates,
                    daily_bars,
                    trading_days,
                    momentum_enrichment or {},
                    config.strategy,
                )
                if _momentum_uses_historical_intraday_first(config.strategy):
                    candidates = _normalize_candidate_map(momentum_seed_candidates)
                else:
                    candidates = momentum_pre_screen_candidates(
                        daily_bars,
                        trading_days,
                        momentum_enrichment or {},
                        threshold=config.backtest.pre_screen_threshold,
                        max_per_day=config.strategy.candidate_final_max_per_day,
                        strategy=config.strategy,
                    )

        total_pairs = sum(len(v) for v in candidates.values())
        print(f"      Pre-screened: {total_pairs} ticker-day pairs across {len(candidates)} days")

        if is_orb:
            from libs.intraday.orb_simulator import run_orb_simulation_with_state

            # Step 4: ORB fetch + simulate in bounded day batches.
            # Large windows (e.g. 90k+ ticker-days) can exceed several GB if every
            # 5-minute bar is materialized in one giant dict before simulation.
            day_chunks = _chunk_trading_days_by_pairs(trading_days, candidates)
            print(
                f"[4/4] Phase 2: Fetching intraday bars ({total_pairs} pairs) "
                f"in {len(day_chunks)} batches..."
            )
            print("      Streaming ORB simulation to keep memory bounded...")

            orb_params = config.orb_strategy or ORBStrategyParams()
            day_results = []
            sim_state = None
            all_intraday: dict[str, dict[str, list[dict]]] = {}
            simulated_days = 0

            for chunk_idx, day_chunk in enumerate(day_chunks, start=1):
                chunk_candidates = {
                    day: candidates[day]
                    for day in day_chunk
                    if candidates.get(day)
                }
                chunk_pairs = sum(len(v) for v in chunk_candidates.values())
                print(
                    f"\n      Batch {chunk_idx}/{len(day_chunks)}: "
                    f"{day_chunk[0]} → {day_chunk[-1]} ({chunk_pairs} pairs)"
                )

                chunk_last_pct = [-1]

                def chunk_intraday_progress(completed: int, total: int, hits: int, calls: int) -> None:
                    if completed == 0 and calls == 0 and total > 0:
                        sys.stdout.write("\n")
                        sys.stdout.flush()
                        chunk_last_pct[0] = -1
                    pct = int(completed / total * 10) * 10 if total > 0 else 0
                    if pct > chunk_last_pct[0] or completed == total:
                        chunk_last_pct[0] = pct
                        sys.stdout.write(
                            f"\r      {_make_progress_bar(completed, total)}  "
                            f"cache:{hits}  api:{calls}"
                        )
                        sys.stdout.flush()

                chunk_intraday = await fetch_intraday_bulk(
                    chunk_candidates,
                    client,
                    cache,
                    skip_oracle_when_unhealthy=True,
                    concurrency=8,
                    progress_callback=chunk_intraday_progress,
                )
                if chunk_pairs > 0:
                    print()

                chunk_results, sim_state = run_orb_simulation_with_state(
                    chunk_intraday,
                    day_chunk,
                    orb_params,
                    enrichment,
                    ticker_sectors=ticker_sectors,
                    state=sim_state,
                    vix_by_day=momentum_vix_by_day,
                    overlay_tickers_per_day=orb_overlay_tickers_per_day,
                )
                day_results.extend(chunk_results)
                simulated_days += len(day_chunk)
                print(f"      Simulated {simulated_days}/{len(trading_days)} days")
                del chunk_intraday

            print(f"\n      Done. Simulated {len(day_results)} days")
        else:
            # Step 4: Phase 2 — Fetch intraday bars (cache-first)
            if recent_intraday_first_scan:
                print(f"[4/4] Phase 2: Reusing recent live-scan intraday bars ({total_pairs} pairs)...")
                shortlisted_intraday: dict[str, dict[str, list[dict]]] = {}
                for day, day_candidates in candidates.items():
                    day_bars = all_intraday.get(day, {})
                    subset = {
                        ticker: day_bars[ticker]
                        for ticker in day_candidates
                        if ticker in day_bars
                    }
                    if subset:
                        shortlisted_intraday[day] = subset
                all_intraday = shortlisted_intraday
                print(f"      Done. {len(all_intraday)} days with shortlisted intraday data")
            else:
                print(f"[4/4] Phase 2: Fetching intraday bars ({total_pairs} pairs)...")
                n_api = [0]

                _intra_last_pct = [-1]

                def intraday_progress(completed: int, total: int, hits: int, calls: int) -> None:
                    n_api[0] = calls
                    if completed == 0 and calls == 0 and total > 0:
                        # Phase 2 reset signal: new total = miss_total, restart bar from 0
                        sys.stdout.write("\n")
                        sys.stdout.flush()
                        _intra_last_pct[0] = -1
                    pct = int(completed / total * 10) * 10 if total > 0 else 0
                    if pct > _intra_last_pct[0] or completed == total:
                        _intra_last_pct[0] = pct
                        sys.stdout.write(
                            f"\r      {_make_progress_bar(completed, total)}  "
                            f"cache:{hits}  api:{calls}"
                        )
                        sys.stdout.flush()

                all_intraday = await fetch_intraday_bulk(
                    candidates,
                    client,
                    cache,
                    skip_oracle_when_unhealthy=True,
                    concurrency=8,
                    progress_callback=intraday_progress,
                )
                print(f"\n      Done. {len(all_intraday)} days with intraday data")

                if _momentum_uses_historical_intraday_first(config.strategy):
                    candidates = momentum_intraday_first_candidates(
                        all_intraday,
                        trading_days,
                        config.strategy,
                        daily_enrichment=momentum_enrichment,
                        ticker_sectors=ticker_sectors,
                        max_per_day=config.strategy.candidate_final_max_per_day,
                    )
                    total_pairs = sum(len(v) for v in candidates.values())
                    shortlisted_intraday: dict[str, dict[str, list[dict]]] = {}
                    for day, day_candidates in candidates.items():
                        day_bars = all_intraday.get(day, {})
                        subset = {
                            ticker: day_bars[ticker]
                            for ticker in day_candidates
                            if ticker in day_bars
                        }
                        if subset:
                            shortlisted_intraday[day] = subset
                    all_intraday = shortlisted_intraday
                    print(
                        "      Intraday-first reranked: "
                        f"{total_pairs} ticker-day pairs across {len(candidates)} days"
                    )

    sector_proxy_intraday: dict[str, dict[str, list[dict]]] | None = None
    if (not is_orb) and _momentum_strategy_uses_sector_proxies(config.strategy):
        print("      Fetching sector ETF proxy bars...")
        proxy_candidates = {day: list(SECTOR_PROXY_TICKERS) for day in trading_days}
        _proxy_last_pct = [-1]

        def proxy_progress(completed: int, total: int, hits: int, calls: int) -> None:
            if completed == 0 and calls == 0 and total > 0:
                sys.stdout.write("\n")
                sys.stdout.flush()
                _proxy_last_pct[0] = -1
            pct = int(completed / total * 10) * 10 if total > 0 else 0
            if pct > _proxy_last_pct[0] or completed == total:
                _proxy_last_pct[0] = pct
                sys.stdout.write(
                    f"\r      {_make_progress_bar(completed, total)}  "
                    f"cache:{hits}  api:{calls}"
                )
                sys.stdout.flush()

        sector_proxy_intraday = await fetch_intraday_bulk(
            proxy_candidates,
            client,
            cache,
            skip_oracle_when_unhealthy=True,
            concurrency=4,
            progress_callback=proxy_progress,
        )
        print(f"\n      Done. {len(sector_proxy_intraday)} days with sector ETF proxy data")

    if not is_orb:
        # Step 5: Simulate (momentum mode still runs after full preload)
        print("\nSimulating trades...")
        _sim_last_pct = [-1]

        def sim_progress(done: int, total: int) -> None:
            pct = int(done / total * 10) * 10 if total > 0 else 0
            if pct > _sim_last_pct[0] or done == total:
                _sim_last_pct[0] = pct
                sys.stdout.write(f"\r  {_make_progress_bar(done, total)}")
                sys.stdout.flush()

        day_results = run_simulation(
            all_intraday,
            trading_days,
            config.strategy,
            daily_enrichment=momentum_enrichment,
            vix_by_day=momentum_vix_by_day,
            ticker_sectors=ticker_sectors,
            sector_proxy_intraday_by_day=sector_proxy_intraday,
        )
        print()

    # Step 6: Compute metrics
    run_id = str(uuid.uuid4())[:8]
    metrics = compute_metrics(day_results, config, run_id=run_id)

    return day_results, metrics, all_intraday, trading_days


async def run_with_sweep(config: IntradayConfig, sweep_path: str) -> None:
    """Run data pipeline once, then sweep over parameter combinations."""
    from apps.intraday_bt.sweep import load_sweep_config, run_sweep

    is_orb = config.strategy_mode == "orb"

    sweep = load_sweep_config(sweep_path, config)
    mode_label = "ORB" if is_orb else "MoMo"
    print(f"\nSweep [{mode_label}] {sweep.total_combinations} combos | " + " | ".join(f"{k}:{v}" for k, v in sweep.sweep_params.items()))

    settings = get_settings()

    cache = IntradayCache(config.cache.dir) if config.cache.enabled else None
    daily_cache = (
        DailyBarCache(str(Path(config.cache.dir).with_name("daily")))
        if config.cache.enabled else None
    )
    event_cache = (
        FilingEventCache(str(Path(config.cache.dir).with_name("orb_catalyst")))
        if config.cache.enabled else None
    )
    attention_cache = (
        AttentionEventCache(str(Path(config.cache.dir).with_name("orb_attention")))
        if config.cache.enabled else None
    )

    async with make_intraday_oracle_client(settings) as client:
        print(f"\n[1/4] Resolving universe ({config.universe.source})...")
        tickers = await resolve_universe(config.universe, client)
        print(f"      {len(tickers)} tickers")

        print("[2/4] Resolving trading calendar...")
        trading_days = await get_trading_days(
            client,
            config.backtest.start_date,
            config.backtest.end_date,
            config.backtest.lookback_trading_days,
        )
        print(f"      {trading_days[0]} → {trading_days[-1]} ({len(trading_days)} days)")
        ticker_sectors = (
            _load_orb_ticker_sectors(tickers)
            if is_orb
            else (
                await _load_ticker_sectors_with_oracle(tickers, client)
                if _momentum_strategy_uses_sector_labels(config.strategy)
                else {}
            )
        )

        print(f"[3/4] Phase 1: Fetching daily bars for {len(tickers)} tickers...")

        _daily_prog_last = [-1]

        def daily_prog(completed: int, total: int) -> None:
            pct = int(completed / total * 10) * 10 if total > 0 else 0
            if pct > _daily_prog_last[0] or completed == total:
                _daily_prog_last[0] = pct
                sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                sys.stdout.flush()

        daily_fetch_start_sw = (
            (date.fromisoformat(trading_days[0]) - timedelta(days=90)).isoformat()
            if (is_orb or _momentum_strategy_uses_daily_enrichment(config.strategy))
            else trading_days[0]
        )
        daily_bars = await fetch_daily_bars_bulk(
            tickers, daily_fetch_start_sw, trading_days[-1], client,
            cache=daily_cache,
            intraday_cache_fallback=cache,
            prefer_intraday_fallback=True,
            skip_oracle_when_unhealthy=True,
            concurrency=20, progress_callback=daily_prog,
        )
        print(f"\n      {len(daily_bars)}/{len(tickers)} with data")

        momentum_enrichment: dict[str, dict[str, dict]] | None = None
        momentum_vix_by_day: dict[str, float] | None = None
        if is_orb:
            # Ensure regime ticker is in daily_bars for market regime filter (sweep mode)
            orb_params_sweep_check = config.orb_strategy or ORBStrategyParams()
            regime_ticker_sw = getattr(orb_params_sweep_check, "market_regime_ticker", "SPY") or "SPY"
            if orb_params_sweep_check.market_regime_spy_threshold is not None and regime_ticker_sw not in daily_bars:
                extra_bars_sw = await fetch_daily_bars_bulk(
                    [regime_ticker_sw], daily_fetch_start_sw, trading_days[-1], client,
                    cache=daily_cache, intraday_cache_fallback=cache, concurrency=1
                )
                daily_bars.update(extra_bars_sw)
            print("      Computing ATR/volume enrichment...")
            enrichment = enrich_daily_bars(daily_bars, trading_days)
            orb_params = config.orb_strategy or ORBStrategyParams()
            candidates = orb_pre_screen_candidates(
                daily_bars, trading_days, enrichment,
                min_price=orb_params.min_price,
                min_atr=orb_params.min_atr_14,
                min_avg_dollar_vol=orb_params.min_avg_dollar_volume,
                max_per_day=None,
            )
            orb_sweep_overlay_tickers_per_day: dict[str, set[str]] | None = None
            if (
                int(getattr(orb_params, "candidate_seed_leader_overlay_slots", 0) or 0) > 0
                or int(getattr(orb_params, "candidate_seed_liquid_overlay_slots", 0) or 0) > 0
            ):
                candidates, orb_sweep_overlay_tickers_per_day = _augment_momentum_seed_candidates_with_liquid_overlay(  # type: ignore[arg-type]
                    candidates, daily_bars, trading_days, enrichment, orb_params
                )
            if _orb_strategy_uses_catalyst(orb_params_sweep_check):
                _prior_lookback_sw = int(getattr(orb_params, "prior_event_lookback_days", 0) or 0)
                if _prior_lookback_sw > 0:
                    all_tickers_sw = list({t for day_tickers in candidates.values() for t in day_tickers})
                    _prior_event_types_sw = tuple(getattr(orb_params, "prior_event_types", None) or ["earnings_release", "guidance_update"])
                    print(f"      Prefetching prior-event features from DB (D-{_prior_lookback_sw}, types={_prior_event_types_sw}) for {len(all_tickers_sw)} tickers...")
                    event_features = await _prefetch_prior_event_features_db(
                        all_tickers_sw, trading_days, lookback_calendar_days=_prior_lookback_sw,
                        event_types=_prior_event_types_sw,
                    )
                    print(f"      Prior-event coverage: {len(event_features)} tickers with events")
                else:
                    event_tickers = _orb_candidate_event_tickers(candidates, enrichment, orb_params)
                    _evt_prog_last = [-1]

                    def event_prog(completed: int, total: int) -> None:
                        pct = int(completed / total * 10) * 10 if total > 0 else 0
                        if pct > _evt_prog_last[0] or completed == total:
                            _evt_prog_last[0] = pct
                            sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                            sys.stdout.flush()

                    event_features = await fetch_filing_event_features_bulk(
                        event_tickers,
                        trading_days[0],
                        trading_days[-1],
                        client,
                        cache=event_cache,
                        concurrency=16,
                        progress_callback=event_prog,
                    )
                    print()
                _merge_orb_event_features(enrichment, event_features)
            if _orb_strategy_uses_vix(orb_params_sweep_check):
                print("      Fetching VIX regime series for ORB sweep...")
                momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days)
        else:
            enrichment = {}
            if _momentum_strategy_requires_regime_ticker_daily(config.strategy):
                regime_ticker_sw = config.strategy.market_regime_gap_ticker or "SPY"
                if regime_ticker_sw not in daily_bars:
                    extra_bars_sw = await fetch_daily_bars_bulk(
                        [regime_ticker_sw],
                        daily_fetch_start_sw,
                        trading_days[-1],
                        client,
                        cache=daily_cache,
                        intraday_cache_fallback=cache,
                        prefer_intraday_fallback=True,
                        skip_oracle_when_unhealthy=True,
                        concurrency=1,
                    )
                    daily_bars.update(extra_bars_sw)
            if _momentum_strategy_uses_daily_enrichment(config.strategy):
                print("      Computing momentum daily enrichment...")
                momentum_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days)
            if _momentum_strategy_uses_vix(config.strategy):
                print("      Fetching VIX regime series...")
                momentum_vix_by_day = await _fetch_vix_by_day(client, trading_days)
            if (
                momentum_enrichment is not None
                and (
                    _momentum_strategy_uses_catalyst(config.strategy)
                    or _momentum_strategy_uses_attention(config.strategy)
                )
            ):
                preliminary_candidates = _normalize_candidate_map(
                    _momentum_intraday_seed_candidates(
                        daily_bars,
                        trading_days,
                        momentum_enrichment,
                        config.strategy,
                        default_threshold=config.backtest.pre_screen_threshold,
                        use_signal_features=False,
                    )
                )
                if _momentum_strategy_uses_catalyst(config.strategy):
                    if _momentum_strategy_uses_candidate_stage_catalyst(config.strategy):
                        event_tickers = sorted(daily_bars.keys())
                    else:
                        event_tickers = _momentum_candidate_event_tickers(preliminary_candidates)
                    print(f"      Fetching momentum filing catalysts for {len(event_tickers)} tickers...")
                    _evt_prog_last = [-1]

                    def event_prog(completed: int, total: int) -> None:
                        pct = int(completed / total * 10) * 10 if total > 0 else 0
                        if pct > _evt_prog_last[0] or completed == total:
                            _evt_prog_last[0] = pct
                            sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                            sys.stdout.flush()

                    event_features = await fetch_filing_event_features_bulk(
                        event_tickers,
                        trading_days[0],
                        trading_days[-1],
                        client,
                        cache=event_cache,
                        concurrency=16,
                        progress_callback=event_prog,
                    )
                    print()
                    _merge_momentum_event_features(momentum_enrichment, event_features)
                if _momentum_strategy_uses_attention(config.strategy):
                    attention_pairs = _momentum_candidate_event_pairs(
                        preliminary_candidates,
                        momentum_enrichment,
                        config.strategy,
                    )
                    print(f"      Fetching momentum attention for {len(attention_pairs)} ticker-days...")
                    _attn_prog_last = [-1]

                    def attention_prog(completed: int, total: int) -> None:
                        pct = int(completed / total * 10) * 10 if total > 0 else 0
                        if pct > _attn_prog_last[0] or completed == total:
                            _attn_prog_last[0] = pct
                            sys.stdout.write(f"\r      {_make_progress_bar(completed, total)}")
                            sys.stdout.flush()

                    attention_features = await fetch_attention_features_bulk(
                        attention_pairs,
                        client,
                        cache=attention_cache,
                        concurrency=16,
                        progress_callback=attention_prog,
                    )
                    print()
                    _merge_momentum_attention_features(momentum_enrichment, attention_features)
            candidates = _momentum_intraday_seed_candidates(
                daily_bars,
                trading_days,
                momentum_enrichment or {},
                config.strategy,
                default_threshold=config.backtest.pre_screen_threshold,
                use_signal_features=True,
            )
            candidates, _ = _augment_momentum_seed_candidates_with_liquid_overlay(
                candidates,
                daily_bars,
                trading_days,
                momentum_enrichment or {},
                config.strategy,
            )
            candidates = _normalize_candidate_map(candidates)

        total_pairs = sum(len(v) for v in candidates.values())
        print(f"      {total_pairs} candidate pairs")

        print(f"[4/4] Phase 2: Fetching intraday bars...")

        _intra_prog_last = [-1]

        def intra_prog(completed: int, total: int, hits: int, calls: int) -> None:
            if completed == 0 and calls == 0 and total > 0:
                sys.stdout.write("\n")
                sys.stdout.flush()
                _intra_prog_last[0] = -1
            pct = int(completed / total * 10) * 10 if total > 0 else 0
            if pct > _intra_prog_last[0] or completed == total:
                _intra_prog_last[0] = pct
                sys.stdout.write(
                    f"\r      {_make_progress_bar(completed, total)}  cache:{hits}  api:{calls}"
                )
                sys.stdout.flush()

        all_intraday = await fetch_intraday_bulk(
            candidates, client, cache, concurrency=8, progress_callback=intra_prog,
        )
        print(f"\n      Done. {len(all_intraday)} days with data")

        if (not is_orb) and _momentum_uses_historical_intraday_first(config.strategy):
            candidates = momentum_intraday_first_candidates(
                all_intraday,
                trading_days,
                config.strategy,
                daily_enrichment=momentum_enrichment,
                ticker_sectors=ticker_sectors,
                max_per_day=config.strategy.candidate_final_max_per_day,
            )
            total_pairs = sum(len(v) for v in candidates.values())
            shortlisted_intraday: dict[str, dict[str, list[dict]]] = {}
            for day, day_candidates in candidates.items():
                day_bars = all_intraday.get(day, {})
                subset = {
                    ticker: day_bars[ticker]
                    for ticker in day_candidates
                    if ticker in day_bars
                }
                if subset:
                    shortlisted_intraday[day] = subset
            all_intraday = shortlisted_intraday
            print(
                "      Intraday-first reranked: "
                f"{total_pairs} ticker-day pairs across {len(candidates)} days"
            )

    sector_proxy_intraday: dict[str, dict[str, list[dict]]] | None = None
    if (not is_orb) and _momentum_strategy_uses_sector_proxies(config.strategy):
        print("      Fetching sector ETF proxy bars for sweep...")
        proxy_candidates = {day: list(SECTOR_PROXY_TICKERS) for day in trading_days}
        _proxy_prog_last = [-1]

        def proxy_prog(completed: int, total: int, hits: int, calls: int) -> None:
            if completed == 0 and calls == 0 and total > 0:
                sys.stdout.write("\n")
                sys.stdout.flush()
                _proxy_prog_last[0] = -1
            pct = int(completed / total * 10) * 10 if total > 0 else 0
            if pct > _proxy_prog_last[0] or completed == total:
                _proxy_prog_last[0] = pct
                sys.stdout.write(
                    f"\r      {_make_progress_bar(completed, total)}  cache:{hits}  api:{calls}"
                )
                sys.stdout.flush()

        sector_proxy_intraday = await fetch_intraday_bulk(
            proxy_candidates,
            client,
            cache,
            concurrency=4,
            progress_callback=proxy_prog,
        )
        print(f"\n      Done. {len(sector_proxy_intraday)} days with sector ETF proxy data")

    print(f"\nRunning {sweep.total_combinations} sweep combinations...")
    completed_sw = [0]
    _sweep_last_pct = [-1]

    def sweep_prog(done: int, total: int) -> None:
        completed_sw[0] = done
        pct = int(done / total * 10) * 10 if total > 0 else 0
        if pct > _sweep_last_pct[0] or done == total:
            _sweep_last_pct[0] = pct
            sys.stdout.write(f"\r  {_make_progress_bar(done, total)}")
            sys.stdout.flush()

    sweep_results = run_sweep(
        sweep, all_intraday, trading_days,
        progress_callback=sweep_prog,
        enrichment=enrichment,
        momentum_enrichment=momentum_enrichment,
        vix_by_day=momentum_vix_by_day,
        ticker_sectors=ticker_sectors if not is_orb else None,
        sector_proxy_intraday_by_day=sector_proxy_intraday,
    )
    print()

    # Display top results
    print(format_sweep_comparison(sweep_results, top_n=20))

    # Also show full details for the #1 configuration
    if sweep_results:
        from apps.intraday_bt.sweep import apply_overrides
        best = sweep_results[0]
        best_config = apply_overrides(config, best.params)
        if is_orb:
            from libs.intraday.orb_simulator import run_orb_simulation
            best_day_results = run_orb_simulation(
                all_intraday, trading_days, best_config.orb_strategy, enrichment,
                vix_by_day=momentum_vix_by_day,
            )
        else:
            best_day_results = run_simulation(
                all_intraday,
                trading_days,
                best_config.strategy,
                daily_enrichment=momentum_enrichment,
                vix_by_day=momentum_vix_by_day,
                ticker_sectors=ticker_sectors,
                sector_proxy_intraday_by_day=sector_proxy_intraday,
            )
        print("\n=== Best Configuration Detail ===")
        print(format_summary(best.metrics, best_config))
        print(format_top_trades(best_day_results, n=5))

    # Save sweep results
    out = Path(config.output.dir)
    out.mkdir(parents=True, exist_ok=True)
    from datetime import datetime
    import json
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    sweep_file = out / f"sweep_{ts}.json"
    sweep_data = [
        {"params": sr.params, "metrics": sr.metrics.model_dump()}
        for sr in sweep_results
    ]
    sweep_file.write_text(json.dumps(sweep_data, indent=2, default=str))
    print(f"\nSweep results saved to: {sweep_file}")


# ── CLI ────────────────────────────────────────────────────────────────────


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Morning Momentum Intraday Backtester",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--config", help="Strategy config YAML file path")
    parser.add_argument("--strategy", choices=["momentum", "orb"], default=None,
                        help="Strategy mode: 'momentum' (default) or 'orb'")
    parser.add_argument("--sweep", help="Parameter sweep YAML file path")
    parser.add_argument("--days", type=int, help="Number of trading days to backtest")
    parser.add_argument("--start", help="Start date YYYY-MM-DD (overrides --days)")
    parser.add_argument("--end", help="End date YYYY-MM-DD (default: today)")
    parser.add_argument("--universe",
                        choices=["sp500", "nasdaq100", "broad", "midlarge", "largecap", "midcap", "smallmid", "screener"],
                        help="Universe source")
    parser.add_argument("--top-n", type=int, dest="top_n", help="Number of stocks to buy per day")
    parser.add_argument("--stop-loss", dest="stop_loss",
                        help="Stop loss % (e.g. -0.02) or 'none' to disable")
    parser.add_argument("--entry-min", type=int, dest="entry_min",
                        help="Minutes after open to enter (default 30)")
    parser.add_argument("--exit-min", type=int, dest="exit_min",
                        help="Minutes before close to exit (default 30)")
    parser.add_argument("--min-gain", type=float, dest="min_gain",
                        help="Minimum morning gain to qualify (default 0.01 = 1%%)")
    parser.add_argument("--no-cache", action="store_true", dest="no_cache",
                        help="Disable cache use for this run")
    parser.add_argument("--refresh-cache", action="store_true", dest="refresh_cache",
                        help="Force re-download all intraday data")
    parser.add_argument("--verbose", action="store_true", help="Show detailed per-day output")
    parser.add_argument("--output-dir", dest="output_dir", help="Override output directory")
    parser.add_argument("--initial-capital", type=float, dest="initial_capital", default=None,
                        help="Override initial capital (e.g. 50000)")
    parser.add_argument("--compound-returns", dest="compound_returns", action="store_true", default=None,
                        help="Override: use compound returns (복리) regardless of config")
    parser.add_argument("--no-compound-returns", dest="compound_returns", action="store_false",
                        help="Override: use simple returns (단리) regardless of config")
    parser.add_argument("--daily-budget-reset", dest="daily_budget_reset", action="store_true", default=None,
                        help="Research mode: reset sizing_capital to initial_capital every day "
                             "(ignores prior PnL; takes precedence over compound_returns)")
    parser.add_argument("--no-daily-budget-reset", dest="daily_budget_reset", action="store_false",
                        help="Disable daily budget reset mode")
    parser.add_argument("--cache-stats", action="store_true", dest="cache_stats",
                        help="Show cache statistics and exit")
    return parser.parse_args()


async def main_async() -> None:
    args = parse_args()

    # Cache stats shortcut
    if args.cache_stats:
        config = load_config(args.config)
        cache = IntradayCache(config.cache.dir)
        stats = cache.stats()
        print(f"Cache directory: {config.cache.dir}")
        print(f"  Files:    {stats['total_files']}")
        print(f"  Size:     {stats['total_mb']} MB")
        print(f"  Tickers:  {stats['tickers']}")
        print(f"  Date range: {stats['date_min']} → {stats['date_max']}")
        return

    # Load + apply config
    config = load_config(args.config)
    config = apply_cli_overrides(config, args)

    # Sweep mode
    if args.sweep:
        await run_with_sweep(config, args.sweep)
        return

    # Single run mode
    day_results, metrics, all_intraday, trading_days = await run(
        config, refresh_cache=args.refresh_cache
    )

    # Display results
    print(format_summary(metrics, config))

    if config.output.verbose or args.verbose:
        print(format_daily_breakdown(day_results))

    print(format_top_trades(day_results, n=5))

    # Always show daily breakdown (compact version)
    if not (config.output.verbose or args.verbose):
        print(format_daily_breakdown(day_results))

    # Save results
    if day_results:
        out_file = write_results(metrics, day_results, config, config.output.dir)
        print(f"\nResults saved to: {out_file}")


def main() -> None:
    asyncio.run(main_async())


if __name__ == "__main__":
    main()
