"""BacktestRunner: main simulation class and CLI entry point."""
from __future__ import annotations

import argparse
import datetime as dt
import json
import math
import statistics
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any

import requests

from libs.backtest.allocator import (
    _cap_shares_by_position_limits,
    _cap_shares_to_remaining_risk_budget,
    _remaining_risk_budget_dollars,
    _resolve_effective_per_trade_risk_pct,
    _resolve_stop_risk_config,
    _resolve_sizing_equity,
    build_planned_order,
    compute_shares,
    compute_stop_price,
)
from libs.backtest.artifacts import create_run_directory, write_all_artifacts
from libs.backtest.domain import (
    BacktestConfig,
    Candidate,
    DailyPortfolioState,
    ExecutionConfig,
    ExitReason,
    ExperimentManifest,
    ExperimentResult,
    FilledTrade,
    MetricsBundle,
    OpenPosition,
    PlannedOrder,
    PositionStatus,
    RobustnessHorizonSummary,
    RobustnessMatrixSummary,
    SplitResult,
    WalkForwardAggregate,
    WalkForwardFoldResult,
    WalkForwardGapStats,
    WalkForwardSummary,
)
from libs.backtest.execution import (
    simulate_scheduled_open_exit,
    simulate_entry,
    simulate_exit,
    simulate_kill_switch_exit,
    simulate_recycle_close_exit,
    update_trailing_stop,
)
from libs.backtest.manifests import generate_run_id, load_manifest, resolve_config
from libs.backtest.metrics import build_metrics_bundle
from libs.backtest.selector import rank_candidates, select_candidates
from libs.backtest.snapshot_store import SnapshotStore
from libs.backtest.splits import generate_robustness_windows, generate_walk_forward_windows
from libs.common.logging import get_logger
from libs.common.time_utils import utc_now
from libs.oracle_client.models import EventAttentionResponse

logger = get_logger(__name__)

_KILL_SWITCH_DRAWDOWN_PCT = 25.0


def _get_git_commit_hash() -> str:
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--short", "HEAD"],
            capture_output=True, text=True, timeout=5,
        )
        return result.stdout.strip() or "unknown"
    except Exception:
        return "unknown"


class BacktestRunner:
    """Event-driven backtester simulation engine."""

    def __init__(
        self,
        manifest: ExperimentManifest,
        config: BacktestConfig,
        store: SnapshotStore,
        initial_equity: float = 100_000.0,
        split_name: str | None = None,
        enable_engine_analysis: bool = True,
    ) -> None:
        self.split_name = split_name
        self.manifest = manifest
        self.config = config
        self.store = store
        self.initial_equity = initial_equity
        self.enable_engine_analysis = enable_engine_analysis
        self._active_strategy_engines = self.config.get_active_strategy_engines()
        self._attention_cache: dict[tuple[str, dt.date], EventAttentionResponse | None] = {}
        self._attention_base_url: str | None = None
        self._attention_session: requests.Session | None = None
        if any(self._engine_requires_attention(engine) for engine in self.config.get_strategy_engines()):
            from libs.common.config import get_settings

            settings = get_settings()
            self._attention_base_url = settings.stock_oracle_url.rstrip("/")
            session = requests.Session()
            session.headers.update({"User-Agent": "fithia2-backtester/1.0"})
            self._attention_session = session

        # Simulation state
        self._equity = initial_equity
        self._cash = initial_equity
        self._open_positions: list[OpenPosition] = []
        self._closed_trades: list[FilledTrade] = []
        self._equity_curve: list[DailyPortfolioState] = []
        self._candidate_map: dict[str, Candidate] = {}  # trade_id → candidate
        self._fixed_capital_sizing = config.risk.fixed_capital_sizing

        # Stats
        self._total_candidates_seen = 0
        self._total_orders_rejected = 0
        self._peak_equity = initial_equity
        self._realized_pnl = 0.0
        self._daily_new_risk_used = 0.0
        self._consecutive_losses = 0
        self._cooldown_remaining = 0
        self._kill_switch_triggered = False
        self._kill_switch_cooldown_remaining = 0
        self._engine_daily_new_risk_used: dict[str, float] = defaultdict(float)
        self._scheduled_add_ons: dict[dt.date, list[Candidate]] = defaultdict(list)
        self._scheduled_delayed_entries: dict[dt.date, list[Candidate]] = defaultdict(list)
        self._recent_scored_candidates: dict[dt.date, list[Candidate]] = {}
        self._pending_open_exits: dict[dt.date, list[dict[str, Any]]] = defaultdict(list)
        self._parent_add_on_counts: dict[str, int] = defaultdict(int)
        self._simulation_dates: list[dt.date] = []
        self._next_trading_day: dict[dt.date, dt.date] = {}

    @property
    def _sizing_equity(self) -> float:
        """Equity used for position sizing. Returns initial_capital when fixed_capital_sizing is enabled."""
        if self._fixed_capital_sizing:
            return self.initial_equity
        return self._equity

    def _compute_portfolio_exposure(self, date: dt.date) -> tuple[float, float]:
        """Return (gross, net) exposure using current close notional when available."""
        gross = 0.0
        net = 0.0
        for pos in self._open_positions:
            bar = self.store.get_bar(pos.plan.candidate.symbol, date)
            close = (
                float(bar["close"])
                if bar and bar.get("close") is not None and float(bar["close"]) > 0
                else pos.entry_price
            )
            notional = close * pos.shares_open
            gross += abs(notional)
            net += -notional if pos.plan.candidate.trade_direction == "short" else notional
        return gross, net

    def _compute_buying_power(self, equity: float, gross_exposure: float) -> float:
        multiplier = self.config.risk.buying_power_multiplier or 1.0
        max_gross = max(0.0, equity * multiplier)
        return max(0.0, max_gross - gross_exposure)

    def run(self, output_root: str | Path | None = None) -> ExperimentResult:
        """Execute the full simulation. Returns ExperimentResult."""
        started_at = utc_now()
        run_id = generate_run_id(self.config)
        logger.info("backtest_start", run_id=run_id, strategy=self.config.strategy_name)

        exec_dates = self.store.all_execution_dates()
        if not exec_dates:
            logger.warning("backtest_no_dates", run_id=run_id)

        # Iterate ALL trading days (not just candidate days) so stop/target/time
        # exits are checked every day, not just on days with new candidates.
        all_dates = self._get_simulation_dates()
        self._simulation_dates = list(all_dates)
        self._next_trading_day = {
            all_dates[idx]: all_dates[idx + 1]
            for idx in range(len(all_dates) - 1)
        }

        # Record initial equity state (before any trades)
        if all_dates:
            self._equity_curve.append(
                DailyPortfolioState(
                    date=all_dates[0],
                    equity=self.initial_equity,
                    sizing_equity=self.initial_equity,
                    cash_available=self._compute_buying_power(self.initial_equity, 0.0),
                    gross_exposure=0.0,
                    net_exposure=0.0,
                    reserved_risk_budget=0.0,
                    unrealized_pnl=0.0,
                    realized_pnl=0.0,
                    open_positions=[],
                    daily_new_risk_used=0.0,
                    peak_equity=self.initial_equity,
                    current_drawdown_pct=0.0,
                )
            )

        for date in all_dates:
            self._simulate_day(date)

        # Force-close any remaining open positions at end of backtest
        if self._open_positions:
            last_date = all_dates[-1] if all_dates else dt.date.today()
            self._force_close_all(last_date, reason="end_of_backtest")

        finished_at = utc_now()
        metrics = build_metrics_bundle(
            self._closed_trades, self._equity_curve, self._candidate_map
        )
        metrics = metrics.model_copy(update=self._build_benchmark_and_contribution_metrics(metrics))
        per_engine_metrics = (
            self._build_per_engine_metrics()
            if self.enable_engine_analysis and self.config.get_strategy_engines()
            else {}
        )

        # Create run directory and write artifacts
        run_dir = None
        artifact_paths: dict[str, str] = {}
        if output_root is not None:
            run_dir = create_run_directory(output_root, run_id)
            git_hash = _get_git_commit_hash()
            artifact_paths = write_all_artifacts(
                run_dir=run_dir,
                run_id=run_id,
                manifest=self.manifest,
                config=self.config,
                metrics=metrics,
                trades=self._closed_trades,
                equity_curve=self._equity_curve,
                open_positions=self._open_positions,
                candidate_map=self._candidate_map,
                started_at=started_at,
                finished_at=finished_at,
                git_hash=git_hash,
                total_trading_days=len(self._equity_curve),
                total_candidates_seen=self._total_candidates_seen,
                total_orders_rejected=self._total_orders_rejected,
                split_name=self.split_name,
                per_engine_metrics=per_engine_metrics,
            )

        logger.info(
            "backtest_complete",
            run_id=run_id,
            trades=len(self._closed_trades),
            days=len(self._equity_curve),
        )

        return ExperimentResult(
            run_id=run_id,
            manifest=self.manifest,
            resolved_config=self.config,
            metrics=metrics,
            artifact_paths=artifact_paths,
            started_at=started_at,
            finished_at=finished_at,
            total_trading_days=len(self._equity_curve),
            total_candidates_seen=self._total_candidates_seen,
            total_orders_rejected=self._total_orders_rejected,
        )

    def _simulate_day(self, date: dt.date) -> None:
        """Simulate a single trading day."""
        # Reset daily risk tracker
        self._daily_new_risk_used = 0.0
        self._engine_daily_new_risk_used = defaultdict(float)

        # Decrement cooldowns
        if self._cooldown_remaining > 0:
            self._cooldown_remaining -= 1
        if self._kill_switch_cooldown_remaining > 0:
            self._kill_switch_cooldown_remaining -= 1

        # Increment days_held for all open positions
        for pos in self._open_positions:
            pos.days_held += 1

        # --- OPENING EXITS (scheduled on prior close) ---
        if self._pending_open_exits.get(date):
            self._process_pending_open_exits(date)

        # --- EXITS FIRST (using today's OHLCV) ---
        # Build position → candidate lookup for attribution mapping
        pos_to_candidate = {pos.position_id: pos.plan.candidate for pos in self._open_positions}

        newly_closed: list[FilledTrade] = []
        still_open: list[OpenPosition] = []

        for pos in self._open_positions:
            bar = self.store.get_bar(pos.plan.candidate.symbol, date)

            # Kill switch: force close
            if self._kill_switch_triggered:
                trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution)
                newly_closed.append(trade)
                continue

            if bar is None:
                # Missing bar — hold position (do not impute zero)
                still_open.append(pos)
                continue

            # Update trailing stop if configured
            if self.config.execution.trailing_model:
                update_trailing_stop(
                    pos, bar,
                    self.config.execution.trailing_model,
                    warmup_days=self.config.execution.trailing_warmup_days,
                )

            effective_exec = self._build_effective_execution_config(pos.plan.candidate)

            prev_status = pos.status
            trade = simulate_exit(pos, bar, effective_exec, date)
            if trade is not None:
                newly_closed.append(trade)
                # Partial exit: status just changed from ENTERED to PARTIALLY_EXITED
                # Keep position open for remaining shares
                if prev_status == PositionStatus.ENTERED and pos.status == PositionStatus.PARTIALLY_EXITED:
                    still_open.append(pos)
            else:
                pending_exit = self._evaluate_pending_open_exit(pos, bar, effective_exec, date)
                if pending_exit is not None:
                    self._queue_pending_open_exit(date, pending_exit)
                still_open.append(pos)

        # Process closed trades
        for trade in newly_closed:
            self._closed_trades.append(trade)
            # Map trade to candidate for attribution
            cand = pos_to_candidate.get(trade.position_id)
            if cand:
                self._candidate_map[trade.trade_id] = cand
            self._realized_pnl += trade.net_pnl
            self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
            # Track consecutive losses for cooldown
            if trade.net_pnl < 0:
                self._consecutive_losses += 1
            else:
                self._consecutive_losses = 0
            if (
                self.config.risk.cooldown_after_loss_streak > 0
                and self._consecutive_losses >= self.config.risk.cooldown_after_loss_streak
            ):
                self._cooldown_remaining = self.config.risk.cooldown_days
                self._consecutive_losses = 0

        self._open_positions = still_open

        # --- Compute current equity for kill-switch check ---
        market_value = self._compute_positions_market_value(date)
        unrealized = market_value - sum(
            p.entry_price * p.shares_open for p in self._open_positions
        )
        self._equity = self._cash + market_value
        self._peak_equity = max(self._peak_equity, self._equity)
        drawdown_pct = (
            (self._peak_equity - self._equity) / self._peak_equity * 100.0
            if self._peak_equity > 0
            else 0.0
        )

        if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not self._kill_switch_triggered:
            logger.warning("kill_switch_triggered", date=str(date), drawdown_pct=drawdown_pct)
            if self.config.risk.kill_switch_log_only:
                logger.info("kill_switch_log_only_mode", date=str(date))
                # Don't trigger — just observe
            else:
                self._kill_switch_triggered = True
                if self.config.risk.backtest_mode == "research":
                    self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days

        # Research mode: reset kill switch after cooldown expires
        # Reset peak_equity to current equity so drawdown restarts from 0
        if (
            self._kill_switch_triggered
            and self.config.risk.backtest_mode == "research"
            and self._kill_switch_cooldown_remaining <= 0
        ):
            self._kill_switch_triggered = False
            self._peak_equity = self._equity
            drawdown_pct = 0.0
            logger.info("kill_switch_reset", date=str(date))

        # --- ENTRIES (only if kill switch not triggered) ---
        if not self._kill_switch_triggered:
            portfolio_state = self._build_portfolio_state(date, drawdown_pct, unrealized)
            candidates = self._select_candidates_for_date(date)

            # Store scored candidates for delayed entry lookback
            if candidates:
                self._recent_scored_candidates[date] = list(candidates)
                # Prune old entries (keep last 10 trading days)
                cutoff = max(0, len(self._simulation_dates) - 15)
                if cutoff > 0:
                    idx = self._simulation_dates.index(date) if date in self._simulation_dates else -1
                    if idx >= 15:
                        old_date = self._simulation_dates[idx - 15]
                        self._recent_scored_candidates.pop(old_date, None)

            # Inject delayed entry candidates
            delayed = self._scheduled_delayed_entries.pop(date, [])
            if delayed:
                candidates = list(candidates) + delayed

            self._total_candidates_seen += len(candidates)

            macro_data = self.store.get_macro_for_date(date)
            candidates = self._reorder_candidates_for_funding(candidates, portfolio_state, macro_data)

            for candidate in candidates:
                plan = build_planned_order(
                    candidate=candidate,
                    portfolio_state=portfolio_state,
                    open_positions=self._open_positions,
                    config=self.config,
                    execution_config=self._build_effective_execution_config(candidate),
                    cooldown_remaining=self._cooldown_remaining,
                    macro_data=macro_data,
                    engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
                )

                if plan.skip_reason is not None:
                    if plan.skip_reason == "insufficient_cash" and self._attempt_same_day_cash_recycle(
                        date=date,
                        candidate=candidate,
                        portfolio_state=portfolio_state,
                    ):
                        mv = self._compute_positions_market_value(date)
                        self._equity = self._cash + mv
                        ur = mv - sum(
                            p.entry_price * p.shares_open
                            for p in self._open_positions
                        )
                        portfolio_state = self._build_portfolio_state(date, drawdown_pct, ur)
                        plan = build_planned_order(
                            candidate=candidate,
                            portfolio_state=portfolio_state,
                            open_positions=self._open_positions,
                            config=self.config,
                            execution_config=self._build_effective_execution_config(candidate),
                            cooldown_remaining=self._cooldown_remaining,
                            macro_data=macro_data,
                            engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
                        )

                if plan.skip_reason is not None:
                    self._total_orders_rejected += 1
                    self._release_add_on_reservation(candidate)
                    logger.debug(
                        "order_rejected",
                        engine_id=candidate.engine_id,
                        symbol=candidate.symbol,
                        reason=plan.skip_reason,
                        date=str(date),
                    )
                    continue

                bar = self.store.get_bar(candidate.symbol, candidate.execution_date)
                gap_skip_reason = self._check_next_open_gap_cap(candidate, bar)
                if gap_skip_reason is not None:
                    self._total_orders_rejected += 1
                    self._release_add_on_reservation(candidate)
                    logger.debug(
                        "order_rejected",
                        engine_id=candidate.engine_id,
                        symbol=candidate.symbol,
                        reason=gap_skip_reason,
                        date=str(date),
                    )
                    continue
                pos = simulate_entry(
                    plan,
                    bar,
                    self._build_effective_execution_config(candidate),
                )
                if pos is not None:
                    pos.parent_position_id = candidate.parent_position_id
                    pos.is_add_on = candidate.is_add_on
                    self._open_positions.append(pos)
                    self._cash -= pos.entry_price * pos.shares_total
                    self._daily_new_risk_used += plan.risk_dollars
                    self._engine_daily_new_risk_used[candidate.engine_id] += plan.risk_dollars
                    # Update equity and portfolio state for next candidate
                    mv = self._compute_positions_market_value(date)
                    self._equity = self._cash + mv
                    ur = mv - sum(
                        p.entry_price * p.shares_open
                        for p in self._open_positions
                    )
                    portfolio_state = self._build_portfolio_state(
                        date, drawdown_pct, ur
                    )
                else:
                    self._release_add_on_reservation(candidate)

        self._schedule_add_on_candidates(date)
        self._schedule_delayed_entry_candidates(date)

        # --- Record daily equity curve snapshot ---
        market_value_final = self._compute_positions_market_value(date)
        unrealized_final = market_value_final - sum(
            p.entry_price * p.shares_open for p in self._open_positions
        )
        self._equity = self._cash + market_value_final
        self._peak_equity = max(self._peak_equity, self._equity)
        final_drawdown = (
            (self._peak_equity - self._equity) / self._peak_equity * 100.0
            if self._peak_equity > 0
            else 0.0
        )
        gross_exposure, net_exposure = self._compute_portfolio_exposure(date)
        self._equity_curve.append(
            DailyPortfolioState(
                date=date,
                equity=self._equity,
                sizing_equity=self._sizing_equity,
                cash_available=self._compute_buying_power(self._equity, gross_exposure),
                gross_exposure=gross_exposure,
                net_exposure=net_exposure,
                reserved_risk_budget=self._daily_new_risk_used,
                unrealized_pnl=unrealized_final,
                realized_pnl=self._realized_pnl,
                open_positions=[p.position_id for p in self._open_positions],
                daily_new_risk_used=self._daily_new_risk_used,
                peak_equity=self._peak_equity,
                current_drawdown_pct=final_drawdown,
            )
        )

    def _get_simulation_dates(self) -> list[dt.date]:
        """Return the full trading-day simulation range for the configured engines."""
        if not self.config.get_strategy_engines():
            return self.store.all_trading_days()
        include_reaction_dates = any(
            engine.entry_timing_policy == "reaction_close"
            for engine in self._active_strategy_engines
        )
        return self.store.all_trading_days(include_reaction_dates=include_reaction_dates)

    def _select_candidates_for_date(self, date: dt.date) -> list[Candidate]:
        """Select daily candidates for single-engine or multi-engine mode."""
        if not self.config.get_strategy_engines():
            raw_rows = self.store.get_candidates_for_date(date)
            return select_candidates(
                raw_rows,
                self.config.universe,
                self.config.signal,
                event_type_profiles=self.config.event_type_profiles or None,
            )
        if not self._active_strategy_engines:
            return []

        engine_queues: dict[str, list[Candidate]] = {}
        reserved_event_ids: set[str] = set()
        reserved_symbols: set[str] = set()
        for engine in self._active_strategy_engines:
            if not self._engine_uses_snapshot_candidates(engine):
                continue
            prelimit = self.config.signal.max_candidates_per_day
            if self._engine_requires_attention(engine):
                prelimit = max(prelimit * 5, prelimit)
            raw_rows = (
                self.store.get_candidates_for_reaction_date(date)
                if engine.entry_timing_policy == "reaction_close"
                else self.store.get_candidates_for_date(date)
            )
            selected = select_candidates(
                raw_rows,
                self.config.universe,
                self.config.signal,
                event_type_profiles=self.config.event_type_profiles or None,
                strategy_engine=engine,
                truncate_to=prelimit,
                excluded_event_ids=reserved_event_ids,
                excluded_symbols=reserved_symbols,
            )
            selected = self._apply_attention_filters(selected, engine)
            if selected:
                engine_queues[engine.engine_id] = selected
                if engine.residual_reserve_selected:
                    reserved_event_ids.update(candidate.event_id for candidate in selected)
                    reserved_symbols.update(candidate.symbol.upper() for candidate in selected)

        scheduled_add_ons = self._scheduled_add_ons.pop(date, [])
        if scheduled_add_ons:
            grouped_add_ons: dict[str, list[Candidate]] = defaultdict(list)
            for candidate in scheduled_add_ons:
                grouped_add_ons[candidate.engine_id].append(candidate)
            for engine_id, candidates in grouped_add_ons.items():
                engine_queues.setdefault(engine_id, [])
                engine_queues[engine_id].extend(rank_candidates(candidates))

        if self.config.strategy_engine_selection_mode == "global_score":
            merged = []
            for candidates in engine_queues.values():
                merged.extend(candidates)
            merged = rank_candidates(merged, self.config.signal.ranking_fields)
            return merged[: self.config.signal.max_candidates_per_day]

        if self.config.strategy_engine_selection_mode == "interleave_head_score":
            return self._interleave_engine_candidates_by_head_score(engine_queues)

        return self._interleave_engine_candidates(engine_queues)

    def _engine_uses_snapshot_candidates(self, engine: Any) -> bool:
        if getattr(engine, "synthetic_only", False):
            return False
        # Backward compatibility: delayed add-ons were always intended to be
        # scheduled synthetic child lots, not direct snapshot candidates.
        if engine.engine_id == "delayed_add_on_long":
            return False
        return True

    def _engine_requires_attention(self, engine: Any) -> bool:
        if (
            self.config.signal.scoring_model in {"return_max_long_v1", "return_max_long_v2", "return_max_long_v3", "return_max_long_v4", "return_max_long_v5", "return_max_long_v6", "return_max_long_v7", "return_max_long_v8", "return_max_long_v9", "return_max_long_v9g", "return_max_long_v10"}
            and engine.direction != "short_only"
        ):
            return True
        return any(
            value is not None
            for value in (
                engine.attention_min_wiki_spike_10d,
                engine.attention_min_wiki_zscore_20d,
                engine.attention_max_wiki_spike_10d,
                engine.attention_max_wiki_zscore_20d,
                engine.attention_min_article_count_3d,
                engine.attention_min_us_article_count_3d,
                engine.attention_min_resolver_confidence,
            )
        )

    def _engine_requires_attention_data(self, engine: Any) -> bool:
        """Return True when the engine has at least one minimum-style gate.

        Minimum gates require an actual attention payload to validate, while
        maximum-only caps can treat missing data as "no veto".
        """
        return any(
            value is not None
            for value in (
                engine.attention_min_wiki_spike_10d,
                engine.attention_min_wiki_zscore_20d,
                engine.attention_min_article_count_3d,
                engine.attention_min_us_article_count_3d,
                engine.attention_min_resolver_confidence,
            )
        )

    def _apply_attention_filters(
        self,
        candidates: list[Candidate],
        engine: Any,
    ) -> list[Candidate]:
        if not candidates or not self._engine_requires_attention(engine):
            return candidates[: self.config.signal.max_candidates_per_day]

        filtered: list[Candidate] = []
        requires_data = self._engine_requires_attention_data(engine)
        threshold = (
            engine.score_threshold_override
            if engine.score_threshold_override is not None
            else self.config.signal.score_threshold
        )
        for candidate in candidates:
            attention = self._get_event_attention(candidate)
            if attention is None:
                if not requires_data:
                    filtered.append(candidate)
                continue
            if not self._passes_attention_filters(engine, attention):
                continue
            enriched = self._attach_attention_features(candidate, attention)
            enriched = self._maybe_rescore_with_attention(enriched)
            if enriched.score >= threshold:
                filtered.append(enriched)

        filtered = rank_candidates(filtered, self.config.signal.ranking_fields)
        return filtered[: self.config.signal.max_candidates_per_day]

    def _get_event_attention(self, candidate: Candidate) -> EventAttentionResponse | None:
        event_date = candidate.event_date or candidate.reaction_date
        cache_key = (candidate.symbol, event_date)
        if cache_key in self._attention_cache:
            return self._attention_cache[cache_key]

        if not self._attention_base_url or self._attention_session is None:
            self._attention_cache[cache_key] = None
            return None

        try:
            response = self._attention_session.get(
                f"{self._attention_base_url}/api/v1/attention/event/{candidate.symbol}",
                params={"event_date": event_date.isoformat()},
                timeout=30,
            )
            if response.status_code >= 400:
                logger.debug(
                    "attention_fetch_failed",
                    symbol=candidate.symbol,
                    event_date=event_date.isoformat(),
                    status_code=response.status_code,
                )
                self._attention_cache[cache_key] = None
                return None
            payload = EventAttentionResponse.model_validate(response.json())
        except Exception as exc:
            logger.warning(
                "attention_fetch_error",
                symbol=candidate.symbol,
                event_date=event_date.isoformat(),
                error=str(exc),
            )
            self._attention_cache[cache_key] = None
            return None

        self._attention_cache[cache_key] = payload
        return payload

    def _passes_attention_filters(
        self,
        engine: Any,
        attention: EventAttentionResponse,
    ) -> bool:
        if (
            engine.attention_min_wiki_spike_10d is not None
            and (
                attention.wiki.spike_10d is None
                or attention.wiki.spike_10d < engine.attention_min_wiki_spike_10d
            )
        ):
            return False
        if (
            engine.attention_min_wiki_zscore_20d is not None
            and (
                attention.wiki.zscore_20d is None
                or attention.wiki.zscore_20d < engine.attention_min_wiki_zscore_20d
            )
        ):
            return False
        if (
            engine.attention_max_wiki_spike_10d is not None
            and (
                attention.wiki.spike_10d is not None
                and attention.wiki.spike_10d > engine.attention_max_wiki_spike_10d
            )
        ):
            return False
        if (
            engine.attention_max_wiki_zscore_20d is not None
            and (
                attention.wiki.zscore_20d is not None
                and attention.wiki.zscore_20d > engine.attention_max_wiki_zscore_20d
            )
        ):
            return False
        if (
            engine.attention_min_article_count_3d is not None
            and attention.news.article_count_3d < engine.attention_min_article_count_3d
        ):
            return False
        if (
            engine.attention_min_us_article_count_3d is not None
            and attention.news.us_article_count_3d < engine.attention_min_us_article_count_3d
        ):
            return False
        if (
            engine.attention_min_resolver_confidence is not None
            and attention.entity.resolver_confidence < engine.attention_min_resolver_confidence
        ):
            return False
        return True

    def _attach_attention_features(
        self,
        candidate: Candidate,
        attention: EventAttentionResponse,
    ) -> Candidate:
        features = dict(candidate.features)
        features.update(
            {
                "attention_wiki_spike_10d": attention.wiki.spike_10d,
                "attention_wiki_zscore_20d": attention.wiki.zscore_20d,
                "attention_article_count_3d": attention.news.article_count_3d,
                "attention_us_article_count_3d": attention.news.us_article_count_3d,
                "attention_gdelt_status": attention.news.gdelt_status,
                "attention_resolver_confidence": attention.entity.resolver_confidence,
            }
        )
        return candidate.model_copy(update={"features": features})

    def _maybe_rescore_with_attention(self, candidate: Candidate) -> Candidate:
        if self.config.signal.scoring_model not in {"return_max_long_v1", "return_max_long_v2", "return_max_long_v3", "return_max_long_v4", "return_max_long_v5", "return_max_long_v6", "return_max_long_v7", "return_max_long_v8"}:
            return candidate
        from libs.backtest.scoring import (
            compute_return_max_long_score,
            compute_return_max_long_score_v2,
            compute_return_max_long_score_v3,
            compute_return_max_long_score_v4,
            compute_return_max_long_score_v5,
            compute_return_max_long_score_v6,
            compute_return_max_long_score_v7,
            compute_return_max_long_score_v8,
            compute_return_max_long_score_v9,
            compute_return_max_long_score_v9g,
            compute_return_max_long_score_v10,
        )

        rescored_features = dict(candidate.features)
        rescored_features.update(
            {
                "event_type": candidate.event_type,
                "event_direction": rescored_features.get("event_direction"),
            }
        )
        if self.config.signal.scoring_model == "return_max_long_v10":
            score = compute_return_max_long_score_v10(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v9g":
            score = compute_return_max_long_score_v9g(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v9":
            score = compute_return_max_long_score_v9(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v8":
            score = compute_return_max_long_score_v8(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v7":
            score = compute_return_max_long_score_v7(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v6":
            score = compute_return_max_long_score_v6(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v3":
            score = compute_return_max_long_score_v3(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v5":
            score = compute_return_max_long_score_v5(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v4":
            score = compute_return_max_long_score_v4(rescored_features)
        elif self.config.signal.scoring_model == "return_max_long_v2":
            score = compute_return_max_long_score_v2(rescored_features)
        else:
            score = compute_return_max_long_score(rescored_features)
        return candidate.model_copy(update={"score": score})

    def _check_next_open_gap_cap(
        self,
        candidate: Candidate,
        bar: dict[str, Any] | None,
    ) -> str | None:
        if candidate.entry_timing_policy != "next_open":
            return None
        if candidate.engine_next_open_gap_cap_pct is None:
            return None
        if bar is None or bar.get("open") is None:
            return None
        reaction_close = candidate.features.get("event_close")
        if reaction_close is None:
            return None
        try:
            reaction_close = float(reaction_close)
        except (TypeError, ValueError):
            return None
        if reaction_close <= 0:
            return None
        gap = float(bar["open"]) / reaction_close - 1.0
        if gap > candidate.engine_next_open_gap_cap_pct:
            return "next_open_gap_cap"
        return None

    def _interleave_engine_candidates(
        self,
        engine_queues: dict[str, list[Candidate]],
    ) -> list[Candidate]:
        """Round-robin engine queues using manifest order."""
        if not engine_queues:
            return []

        working = {
            engine_id: list(candidates)
            for engine_id, candidates in engine_queues.items()
        }
        ordered: list[Candidate] = []
        while True:
            advanced = False
            for engine in self._active_strategy_engines:
                queue = working.get(engine.engine_id, [])
                if not queue:
                    continue
                ordered.append(queue.pop(0))
                advanced = True
            if not advanced:
                break
        return ordered[: self.config.signal.max_candidates_per_day]

    def _interleave_engine_candidates_by_head_score(
        self,
        engine_queues: dict[str, list[Candidate]],
    ) -> list[Candidate]:
        """Round-robin by picking the strongest current head candidate each turn."""
        if not engine_queues:
            return []

        working = {
            engine_id: list(candidates)
            for engine_id, candidates in engine_queues.items()
        }
        engine_order = {
            engine.engine_id: index
            for index, engine in enumerate(self._active_strategy_engines)
        }
        ordered: list[Candidate] = []
        while True:
            head_pool: list[tuple[float, int, Candidate]] = []
            for engine in self._active_strategy_engines:
                queue = working.get(engine.engine_id, [])
                if not queue:
                    continue
                candidate = queue[0]
                head_pool.append(
                    (
                        candidate.score,
                        -engine_order.get(engine.engine_id, 0),
                        candidate,
                    )
                )
            if not head_pool:
                break

            _, _, winner = max(head_pool, key=lambda item: (item[0], item[1]))
            ordered.append(working[winner.engine_id].pop(0))

        return ordered[: self.config.signal.max_candidates_per_day]

    def _reorder_candidates_for_funding(
        self,
        candidates: list[Candidate],
        portfolio_state: DailyPortfolioState,
        macro_data: dict[str, Any] | None,
    ) -> list[Candidate]:
        mode = self.config.strategy_engine_selection_mode
        if mode not in {
            "interleave_cap_efficiency_soft",
            "interleave_cap_efficiency_strict",
            "interleave_cash_tiebreak",
        }:
            return candidates

        ranked: list[tuple[float, float, int, Candidate]] = []
        skipped: list[tuple[int, Candidate]] = []
        for idx, candidate in enumerate(candidates):
            plan = build_planned_order(
                candidate=candidate,
                portfolio_state=portfolio_state,
                open_positions=self._open_positions,
                config=self.config,
                execution_config=self._build_effective_execution_config(candidate),
                cooldown_remaining=self._cooldown_remaining,
                macro_data=macro_data,
                engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
            )
            if plan.skip_reason is not None or plan.shares <= 0:
                skipped.append((idx, candidate))
                continue
            estimated_cash = max(float(plan.shares * candidate.entry_price_est), 1.0)
            if mode == "interleave_cash_tiebreak":
                score_band = math.floor(candidate.score / 0.02)
                efficiency = float(score_band)
                cash_rank = estimated_cash
            elif mode == "interleave_cap_efficiency_strict":
                efficiency = candidate.score / estimated_cash
                cash_rank = candidate.score
            else:
                efficiency = candidate.score / math.sqrt(estimated_cash)
                cash_rank = candidate.score
            ranked.append((efficiency, cash_rank, idx, candidate))

        if mode == "interleave_cash_tiebreak":
            ranked.sort(key=lambda item: (-item[0], item[1], item[2]))
        else:
            ranked.sort(key=lambda item: (-item[0], -item[1], item[2]))
        ordered = [candidate for _, _, _, candidate in ranked]
        ordered.extend(candidate for _, candidate in sorted(skipped, key=lambda item: item[0]))
        return ordered

    def _build_effective_execution_config(self, candidate: Candidate) -> ExecutionConfig:
        """Resolve per-engine and per-event execution overrides."""
        execution_updates: dict[str, Any] = {}

        max_holding_days = candidate.engine_max_holding_days
        if max_holding_days is None:
            evt_profile = self.config.get_event_profile(candidate.event_type)
            if evt_profile and evt_profile.max_holding_days_override is not None:
                max_holding_days = evt_profile.max_holding_days_override
        if max_holding_days is not None:
            execution_updates["max_holding_days"] = max_holding_days

        if candidate.engine_target_atr_multiplier is not None:
            execution_updates["target_atr_multiplier"] = candidate.engine_target_atr_multiplier
        if candidate.engine_trailing_model is not None:
            execution_updates["trailing_model"] = candidate.engine_trailing_model
        if candidate.engine_trailing_warmup_days is not None:
            execution_updates["trailing_warmup_days"] = candidate.engine_trailing_warmup_days
        if candidate.engine_early_failure_close_below_entry_and_reaction_close is not None:
            execution_updates["early_failure_close_below_entry_and_reaction_close"] = (
                candidate.engine_early_failure_close_below_entry_and_reaction_close
            )
        if candidate.engine_early_failure_no_progress_days is not None:
            execution_updates["early_failure_no_progress_days"] = (
                candidate.engine_early_failure_no_progress_days
            )
        if candidate.engine_early_failure_no_progress_r is not None:
            execution_updates["early_failure_no_progress_r"] = (
                candidate.engine_early_failure_no_progress_r
            )
        if candidate.engine_early_failure_no_progress_fraction is not None:
            execution_updates["early_failure_no_progress_fraction"] = (
                candidate.engine_early_failure_no_progress_fraction
            )
        if self.config.execution.use_tiered_targets and self.config.signal.a_tier_score_threshold is not None:
            if candidate.score >= self.config.signal.a_tier_score_threshold:
                if self.config.execution.a_tier_target_1_r is not None:
                    execution_updates["target_1_r"] = self.config.execution.a_tier_target_1_r
                if self.config.execution.a_tier_target_1_fraction is not None:
                    execution_updates["target_1_fraction"] = self.config.execution.a_tier_target_1_fraction
            else:
                if self.config.execution.non_a_tier_target_1_r is not None:
                    execution_updates["target_1_r"] = self.config.execution.non_a_tier_target_1_r
                if self.config.execution.non_a_tier_target_1_fraction is not None:
                    execution_updates["target_1_fraction"] = self.config.execution.non_a_tier_target_1_fraction
        if candidate.engine_target_1_r is not None:
            execution_updates["target_1_r"] = candidate.engine_target_1_r
        if candidate.engine_target_1_fraction is not None:
            execution_updates["target_1_fraction"] = candidate.engine_target_1_fraction

        # Adaptive exit: adjust trailing warmup based on close_location zone.
        # Only overrides trailing_warmup_days (not max_holding_days) to avoid
        # cutting profitable drift trades short.
        exec_cfg = self.config.execution
        if exec_cfg.adaptive_exit_enabled:
            cl = candidate.features.get("close_location")
            if cl is not None:
                try:
                    cl_val = float(cl)
                except (TypeError, ValueError):
                    cl_val = None
                if cl_val is not None:
                    if cl_val >= exec_cfg.adaptive_exit_exhaustion_close_min:
                        execution_updates["trailing_warmup_days"] = exec_cfg.adaptive_exit_exhaustion_trailing_warmup
                    elif exec_cfg.adaptive_exit_orderly_close_min <= cl_val <= exec_cfg.adaptive_exit_orderly_close_max:
                        execution_updates["trailing_warmup_days"] = exec_cfg.adaptive_exit_orderly_trailing_warmup

        if not execution_updates:
            return self.config.execution
        return self.config.execution.model_copy(update=execution_updates)

    def _build_per_engine_metrics(self) -> dict[str, dict[str, Any]]:
        """Compute per-engine trade attribution from the main run's trades.

        Instead of re-running the full backtest N times (one per engine),
        group actual trades by engine_id and compute metrics for each group.
        """
        # Group trades by engine_id
        engine_trades: dict[str, list[FilledTrade]] = defaultdict(list)
        engine_candidates: dict[str, dict[str, Candidate]] = defaultdict(dict)
        for trade in self._closed_trades:
            cand = self._candidate_map.get(trade.trade_id)
            eid = cand.engine_id if cand else "unknown"
            engine_trades[eid].append(trade)
            if cand:
                engine_candidates[eid][trade.trade_id] = cand

        summaries: dict[str, dict[str, Any]] = {}
        for engine in self.config.get_strategy_engines():
            eid = engine.engine_id
            trades = engine_trades.get(eid, [])
            cand_map = engine_candidates.get(eid, {})

            if trades:
                engine_metrics = build_metrics_bundle(trades, self._equity_curve, cand_map)
                metrics_dict = engine_metrics.model_dump(mode="json")
            else:
                metrics_dict = MetricsBundle().model_dump(mode="json")

            summaries[eid] = {
                "engine_id": eid,
                "shadow_only": engine.shadow_only,
                "event_types": list(engine.event_types),
                "timing_class": engine.timing_class,
                "direction": engine.direction,
                "entry_timing_policy": engine.entry_timing_policy,
                "max_holding_days": engine.max_holding_days,
                "engine_risk_budget_pct": engine.engine_risk_budget_pct,
                "target_atr_multiplier_override": engine.target_atr_multiplier_override,
                "target_1_r_override": engine.target_1_r_override,
                "target_1_fraction_override": engine.target_1_fraction_override,
                "trailing_model_override": engine.trailing_model_override,
                "trailing_warmup_days_override": engine.trailing_warmup_days_override,
                "trade_count": len(trades),
                "net_pnl": round(sum(t.net_pnl for t in trades), 4),
                "win_rate": (
                    round(sum(1 for t in trades if t.net_pnl > 0) / len(trades), 4)
                    if trades else None
                ),
                "metrics": metrics_dict,
            }
        return summaries

    def _is_a_tier(self, candidate: Candidate) -> bool:
        threshold = self.config.signal.a_tier_score_threshold
        return threshold is not None and candidate.score >= threshold

    def _macro_regime_state_for_date(self, date: dt.date) -> str:
        macro_data = self.store.get_macro_for_date(date)
        if not self.config.risk.macro_regime_enabled:
            return "disabled"
        if self.config.risk.macro_regime_mode == "spy_qqq_scaler":
            spy_close = macro_data.get("spy_close")
            spy_sma = macro_data.get("spy_sma_20")
            qqq_close = macro_data.get("qqq_close")
            qqq_sma = macro_data.get("qqq_sma_20")
            if None in (spy_close, spy_sma, qqq_close, qqq_sma):
                return "unknown"
            spy_on = float(spy_close) >= float(spy_sma)
            qqq_on = float(qqq_close) >= float(qqq_sma)
            if spy_on and qqq_on:
                return "risk_on"
            if spy_on or qqq_on:
                return "neutral"
            return "risk_off"
        spy_close = macro_data.get("spy_close")
        spy_sma = macro_data.get("spy_sma_20")
        if spy_close is None or spy_sma is None:
            return "unknown"
        return "risk_off" if float(spy_close) < float(spy_sma) else "risk_on"

    def _queue_pending_open_exit(self, date: dt.date, payload: dict[str, Any]) -> None:
        next_date = self._next_trading_day.get(date)
        if next_date is None:
            return
        existing = self._pending_open_exits.get(next_date, [])
        if any(
            item.get("position_id") == payload.get("position_id")
            and item.get("reason") == payload.get("reason")
            for item in existing
        ):
            return
        self._pending_open_exits[next_date].append(payload)

    def _evaluate_pending_open_exit(
        self,
        position: OpenPosition,
        bar: dict[str, Any],
        execution_config: ExecutionConfig,
        date: dt.date,
    ) -> dict[str, Any] | None:
        if position.plan.candidate.trade_direction != "long":
            return None

        close_value = bar.get("close")
        if close_value is None:
            return None
        close_value = float(close_value)

        reaction_close = position.plan.candidate.features.get("event_close")
        if reaction_close is None:
            reaction_close = position.plan.candidate.entry_price_est
        try:
            reaction_close = float(reaction_close)
        except (TypeError, ValueError):
            reaction_close = position.plan.candidate.entry_price_est

        if (
            execution_config.early_failure_close_below_entry_and_reaction_close
            and position.days_held == 1
            and close_value < position.entry_price
            and close_value < reaction_close
        ):
            return {
                "position_id": position.position_id,
                "reason": "EARLY_FAILURE",
                "fraction": 1.0,
            }

        no_progress_days = execution_config.early_failure_no_progress_days
        no_progress_r = execution_config.early_failure_no_progress_r
        if (
            no_progress_days is not None
            and no_progress_r is not None
            and position.days_held >= no_progress_days
            and position.days_held == no_progress_days
            and position.status != PositionStatus.PARTIALLY_EXITED
        ):
            initial_r = abs(position.entry_price - position.plan.stop_price)
            progress_price = position.entry_price + initial_r * no_progress_r
            if close_value < progress_price:
                return {
                    "position_id": position.position_id,
                    "reason": "NO_PROGRESS",
                    "fraction": execution_config.early_failure_no_progress_fraction or 0.5,
                }

        trigger_r = execution_config.early_pop_giveback_trigger_r
        min_r = execution_config.early_pop_giveback_min_r
        from_peak_pct = execution_config.early_pop_giveback_from_peak_pct
        if (
            trigger_r is not None
            and min_r is not None
            and from_peak_pct is not None
            and position.status != PositionStatus.PARTIALLY_EXITED
        ):
            event_direction = str(position.plan.candidate.features.get("event_direction", "")).lower()
            guidance_status = str(position.plan.candidate.features.get("guidance_status", "")).lower()
            is_unknown_inline = (
                event_direction == "unknown"
                and guidance_status == "inline_or_maintained"
            )
            days_min = execution_config.early_pop_giveback_days_min or 1
            days_max = execution_config.early_pop_giveback_days_max or position.days_held
            if not is_unknown_inline and days_min <= position.days_held <= days_max:
                initial_r = abs(position.entry_price - position.plan.stop_price)
                if initial_r > 0:
                    peak_progress = position.peak_price - position.entry_price
                    close_progress = close_value - position.entry_price
                    gave_back_r = close_progress < (initial_r * min_r)
                    gave_back_pct = close_value < (position.peak_price * (1.0 - from_peak_pct))
                    if peak_progress >= (initial_r * trigger_r) and (gave_back_r or gave_back_pct):
                        return {
                            "position_id": position.position_id,
                            "reason": "GIVEBACK",
                            "fraction": execution_config.early_pop_giveback_fraction or 1.0,
                        }
        return None

    def _process_pending_open_exits(self, date: dt.date) -> None:
        payloads = self._pending_open_exits.pop(date, [])
        if not payloads:
            return

        by_position_id = {payload["position_id"]: payload for payload in payloads}
        remaining_positions: list[OpenPosition] = []
        for position in self._open_positions:
            payload = by_position_id.get(position.position_id)
            if payload is None:
                remaining_positions.append(position)
                continue

            bar = self.store.get_bar(position.plan.candidate.symbol, date)
            if bar is None:
                remaining_positions.append(position)
                continue

            trade = simulate_scheduled_open_exit(
                position=position,
                bar=bar,
                config=self._build_effective_execution_config(position.plan.candidate),
                current_date=date,
                reason=payload["reason"],
                fraction=float(payload.get("fraction", 1.0)),
            )
            if trade is None:
                remaining_positions.append(position)
                continue

            self._closed_trades.append(trade)
            self._candidate_map[trade.trade_id] = position.plan.candidate
            self._realized_pnl += trade.net_pnl
            self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
            if trade.net_pnl < 0:
                self._consecutive_losses += 1
            else:
                self._consecutive_losses = 0
            if (
                self.config.risk.cooldown_after_loss_streak > 0
                and self._consecutive_losses >= self.config.risk.cooldown_after_loss_streak
            ):
                self._cooldown_remaining = self.config.risk.cooldown_days
                self._consecutive_losses = 0
            if position.shares_open > 0:
                remaining_positions.append(position)

        self._open_positions = remaining_positions

    def _schedule_add_on_candidates(self, date: dt.date) -> None:
        next_date = self._next_trading_day.get(date)
        if next_date is None:
            return
        add_on_engine = next(
            (engine for engine in self._active_strategy_engines if engine.engine_id == "delayed_add_on_long"),
            None,
        )
        if add_on_engine is None:
            return
        if self._macro_regime_state_for_date(date) == "risk_off":
            return

        for position in self._open_positions:
            if position.is_add_on:
                continue
            current_add_on_count = self._parent_add_on_counts.get(position.position_id, 0)
            max_add_on_count = max(1, add_on_engine.add_on_max_count)
            if current_add_on_count >= max_add_on_count:
                continue
            if position.plan.candidate.trade_direction != "long":
                continue
            min_days_held = add_on_engine.add_on_min_parent_days_held or 1
            max_days_held = add_on_engine.add_on_max_parent_days_held or 2
            if position.days_held < min_days_held or position.days_held > max_days_held:
                continue
            if (
                add_on_engine.add_on_schedule_days
                and position.days_held not in set(add_on_engine.add_on_schedule_days)
            ):
                continue
            if (
                add_on_engine.add_on_parent_score_min is not None
                and position.plan.candidate.score < add_on_engine.add_on_parent_score_min
            ):
                continue
            if (
                add_on_engine.add_on_parent_engine_ids
                and position.plan.candidate.engine_id not in add_on_engine.add_on_parent_engine_ids
            ):
                continue

            bar = self.store.get_bar(position.plan.candidate.symbol, date)
            if bar is None or bar.get("close") is None:
                continue

            close_value = float(bar["close"])
            if close_value <= position.entry_price:
                continue

            reaction_close = position.plan.candidate.features.get("event_close")
            if reaction_close is None:
                reaction_close = position.plan.candidate.entry_price_est
            try:
                reaction_close = float(reaction_close)
            except (TypeError, ValueError):
                reaction_close = position.plan.candidate.entry_price_est
            if close_value <= reaction_close:
                continue

            high = bar.get("high")
            low = bar.get("low")
            if high is None or low is None or float(high) <= float(low):
                continue
            close_location = (close_value - float(low)) / (float(high) - float(low))
            close_location_min = add_on_engine.add_on_close_location_min or 0.65
            if close_location < close_location_min:
                continue

            initial_r = abs(position.entry_price - position.plan.stop_price)
            progress_levels = add_on_engine.add_on_progress_r_levels or []
            if progress_levels:
                progress_r_min = progress_levels[min(current_add_on_count, len(progress_levels) - 1)]
            else:
                progress_r_min = add_on_engine.add_on_progress_r_min or 0.5
            if close_value <= position.entry_price + (progress_r_min * initial_r):
                continue
            if add_on_engine.add_on_require_above_reaction_high:
                reaction_high = position.plan.candidate.features.get("reaction_day_high")
                try:
                    if reaction_high is None or close_value <= float(reaction_high):
                        continue
                except (TypeError, ValueError):
                    continue

            size_fraction = add_on_engine.add_on_size_fraction or 0.5
            forced_shares = max(1, int(position.shares_open * size_fraction))
            candidate = position.plan.candidate.model_copy(
                update={
                    "engine_id": add_on_engine.engine_id,
                    "entry_timing_policy": "next_open",
                    "execution_date": next_date,
                    "reaction_date": date,
                    "entry_price_est": close_value,
                    "engine_max_holding_days": add_on_engine.max_holding_days,
                    "engine_risk_budget_pct": add_on_engine.engine_risk_budget_pct,
                    "engine_target_atr_multiplier": add_on_engine.target_atr_multiplier_override,
                    "engine_target_1_r": add_on_engine.target_1_r_override,
                    "engine_target_1_fraction": add_on_engine.target_1_fraction_override,
                    "engine_trailing_model": add_on_engine.trailing_model_override,
                    "engine_trailing_warmup_days": add_on_engine.trailing_warmup_days_override,
                    "engine_next_open_gap_cap_pct": add_on_engine.next_open_gap_cap_pct,
                    "engine_add_on_max_count": add_on_engine.add_on_max_count,
                    "engine_add_on_size_fraction": add_on_engine.add_on_size_fraction,
                    "parent_position_id": position.position_id,
                    "is_add_on": True,
                    "forced_shares": forced_shares,
                    "features": {
                        **position.plan.candidate.features,
                        "add_on_signal_date": date.isoformat(),
                        "add_on_close_location": close_location,
                        "add_on_progress_r_min": progress_r_min,
                        "add_on_index": current_add_on_count + 1,
                    },
                }
            )
            self._scheduled_add_ons[next_date].append(candidate)
            self._parent_add_on_counts[position.position_id] += 1

    def _schedule_delayed_entry_candidates(self, date: dt.date) -> None:
        """Generate delayed-entry candidates from past events where drift is confirmed.

        For engines with delayed_entry_lookback_days set, look back N trading days
        to find scored candidates whose price has continued drifting upward.
        This captures the continuation phase of PEAD after initial momentum is confirmed.
        """
        next_date = self._next_trading_day.get(date)
        if next_date is None:
            return

        delayed_engines = [
            e for e in self._active_strategy_engines
            if e.delayed_entry_lookback_days is not None
        ]
        if not delayed_engines:
            return

        open_symbols = {p.plan.candidate.symbol for p in self._open_positions}

        for engine in delayed_engines:
            lookback = engine.delayed_entry_lookback_days
            source_engine_ids = set(engine.delayed_entry_source_engine_ids or [])
            min_drift = engine.delayed_entry_min_drift_pct or 0.0
            cl_min = engine.delayed_entry_close_location_min or 0.50

            # Find the date that was `lookback` trading days ago
            try:
                sim_idx = self._simulation_dates.index(date)
            except ValueError:
                continue
            if sim_idx < lookback:
                continue
            lookback_date = self._simulation_dates[sim_idx - lookback]

            past_candidates = self._recent_scored_candidates.get(lookback_date, [])
            for past_cand in past_candidates:
                if source_engine_ids and past_cand.engine_id not in source_engine_ids:
                    continue
                if past_cand.symbol in open_symbols:
                    continue
                if past_cand.trade_direction != "long":
                    continue

                bar = self.store.get_bar(past_cand.symbol, date)
                if bar is None or bar.get("close") is None:
                    continue

                close_value = float(bar["close"])
                reaction_close = past_cand.entry_price_est
                if reaction_close <= 0:
                    continue

                drift_pct = (close_value - reaction_close) / reaction_close
                if drift_pct < min_drift:
                    continue

                high = bar.get("high")
                low = bar.get("low")
                if high is None or low is None or float(high) <= float(low):
                    continue
                today_cl = (close_value - float(low)) / (float(high) - float(low))
                if today_cl < cl_min:
                    continue

                candidate = past_cand.model_copy(
                    update={
                        "engine_id": engine.engine_id,
                        "entry_timing_policy": "next_open",
                        "execution_date": next_date,
                        "reaction_date": date,
                        "entry_price_est": close_value,
                        "engine_max_holding_days": engine.max_holding_days,
                        "engine_risk_budget_pct": engine.engine_risk_budget_pct,
                        "engine_per_trade_risk_pct": engine.per_trade_risk_pct_override,
                        "engine_target_1_r": engine.target_1_r_override,
                        "engine_target_1_fraction": engine.target_1_fraction_override,
                        "engine_trailing_model": engine.trailing_model_override,
                        "engine_trailing_warmup_days": engine.trailing_warmup_days_override,
                        "engine_stop_atr_multiplier": engine.stop_atr_multiplier_override,
                        "engine_next_open_gap_cap_pct": engine.next_open_gap_cap_pct,
                        "engine_use_reaction_day_low_stop": False,
                        "shadow_only": engine.shadow_only,
                        "is_add_on": False,
                        "parent_position_id": None,
                        "forced_shares": None,
                        "features": {
                            **past_cand.features,
                            "delayed_entry_signal_date": date.isoformat(),
                            "delayed_entry_drift_pct": round(drift_pct, 4),
                            "delayed_entry_close_location": round(today_cl, 4),
                        },
                    }
                )
                self._scheduled_delayed_entries[next_date].append(candidate)

    def _release_add_on_reservation(self, candidate: Candidate) -> None:
        if not candidate.is_add_on or candidate.parent_position_id is None:
            return
        reserved = self._parent_add_on_counts.get(candidate.parent_position_id, 0)
        if reserved <= 0:
            return
        self._parent_add_on_counts[candidate.parent_position_id] = reserved - 1

    def _build_benchmark_and_contribution_metrics(self, metrics: MetricsBundle) -> dict[str, Any]:
        updates: dict[str, Any] = {}
        qqq_return = self._compute_qqq_benchmark_return_pct()
        updates["qqq_benchmark_return_pct"] = qqq_return
        updates["excess_vs_qqq_pct"] = (
            metrics.total_return_pct - qqq_return
            if metrics.total_return_pct is not None and qqq_return is not None
            else None
        )

        short_net_pnl = sum(
            trade.net_pnl
            for trade in self._closed_trades
            if self._candidate_map.get(trade.trade_id) is not None
            and self._candidate_map[trade.trade_id].trade_direction == "short"
        )
        long_net_pnl = sum(
            trade.net_pnl
            for trade in self._closed_trades
            if self._candidate_map.get(trade.trade_id) is not None
            and self._candidate_map[trade.trade_id].trade_direction == "long"
        )
        total_net_pnl = long_net_pnl + short_net_pnl
        updates["long_net_pnl"] = round(long_net_pnl, 4)
        updates["short_net_pnl"] = round(short_net_pnl, 4)
        if total_net_pnl != 0:
            updates["long_pnl_contribution_pct"] = long_net_pnl / total_net_pnl * 100.0
            updates["short_pnl_contribution_pct"] = short_net_pnl / total_net_pnl * 100.0
        else:
            updates["long_pnl_contribution_pct"] = None
            updates["short_pnl_contribution_pct"] = None
        return updates

    def _compute_qqq_benchmark_return_pct(self) -> float | None:
        if not self._equity_curve:
            return None
        first = None
        last = None
        for state in self._equity_curve:
            qqq_close = self.store.get_macro_for_date(state.date).get("qqq_close")
            if qqq_close is None:
                continue
            if first is None:
                first = qqq_close
            last = qqq_close
        if first in (None, 0) or last is None:
            return None
        return (float(last) - float(first)) / float(first) * 100.0

    def _compute_positions_market_value(self, date: dt.date) -> float:
        """Market value of all open positions using today's close.

        For long: market_value = close * shares.
        For short: market_value = (2 * entry - close) * shares.
          This reflects that a short position gains when price falls:
          the "value" of a short at entry is entry_price * shares,
          and PnL = (entry - close) * shares, so effective value = entry + PnL = (2*entry - close).

        Falls back to entry_price when bar is missing (assumes no change
        rather than treating the position as worthless).
        """
        total = 0.0
        for pos in self._open_positions:
            bar = self.store.get_bar(pos.plan.candidate.symbol, date)
            is_short = pos.plan.candidate.trade_direction == "short"
            if bar and bar.get("close"):
                close = float(bar["close"])
                if is_short:
                    total += (2.0 * pos.entry_price - close) * pos.shares_open
                else:
                    total += close * pos.shares_open
            else:
                total += pos.entry_price * pos.shares_open
        return total

    def _compute_unrealized_pnl(self, date: dt.date) -> float:
        """Unrealized PnL = market_value − cost_basis."""
        market_value = self._compute_positions_market_value(date)
        cost_basis = sum(p.entry_price * p.shares_open for p in self._open_positions)
        return market_value - cost_basis

    def _build_portfolio_state(
        self,
        date: dt.date,
        drawdown_pct: float,
        unrealized: float,
    ) -> DailyPortfolioState:
        gross_exposure, net_exposure = self._compute_portfolio_exposure(date)
        return DailyPortfolioState(
            date=date,
            equity=self._equity,
            sizing_equity=self._sizing_equity,
            cash_available=self._compute_buying_power(self._equity, gross_exposure),
            gross_exposure=gross_exposure,
            net_exposure=net_exposure,
            reserved_risk_budget=self._daily_new_risk_used,
            unrealized_pnl=unrealized,
            realized_pnl=self._realized_pnl,
            open_positions=[p.position_id for p in self._open_positions],
            daily_new_risk_used=self._daily_new_risk_used,
            peak_equity=self._peak_equity,
            current_drawdown_pct=drawdown_pct,
        )

    def _attempt_same_day_cash_recycle(
        self,
        date: dt.date,
        candidate: Candidate,
        portfolio_state: DailyPortfolioState,
    ) -> bool:
        engine = next(
            (item for item in self._active_strategy_engines if item.engine_id == candidate.engine_id),
            None,
        )
        if engine is None or not engine.recycle_on_cash_block:
            return False
        if candidate.trade_direction != "long":
            return False
        if candidate.entry_timing_policy not in {"reaction_close", "next_open"}:
            return False

        shortfall = self._estimate_cash_shortfall(candidate, portfolio_state)
        if shortfall <= 0:
            return False

        victim = self._select_recycle_victim(
            date=date,
            candidate=candidate,
            shortfall=shortfall,
            engine=engine,
        )
        if victim is None:
            return False

        return self._execute_same_day_recycle_exit(victim, date, candidate)

    def _estimate_cash_shortfall(
        self,
        candidate: Candidate,
        portfolio_state: DailyPortfolioState,
    ) -> float:
        trade_risk_pct = _resolve_effective_per_trade_risk_pct(candidate, self.config)
        stop_price = compute_stop_price(candidate, _resolve_stop_risk_config(candidate, self.config))
        shares = compute_shares(
            _resolve_sizing_equity(portfolio_state),
            candidate.entry_price_est,
            stop_price,
            self.config.risk,
            risk_pct_override=trade_risk_pct,
        )
        shares = _cap_shares_by_position_limits(shares, candidate, portfolio_state, self.config)
        if self.config.risk.allow_budget_downsizing:
            remaining_risk, _, _ = _remaining_risk_budget_dollars(
                candidate,
                portfolio_state,
                self.config,
                engine_daily_new_risk_used=self._engine_daily_new_risk_used[candidate.engine_id],
            )
            shares = _cap_shares_to_remaining_risk_budget(
                shares,
                candidate.entry_price_est,
                stop_price,
                remaining_risk,
            )
        required_notional = max(0.0, shares * float(candidate.entry_price_est))
        return max(0.0, required_notional - portfolio_state.cash_available)

    def _select_recycle_victim(
        self,
        *,
        date: dt.date,
        candidate: Candidate,
        shortfall: float,
        engine: Any,
    ) -> OpenPosition | None:
        allowed_victims = set(engine.recycle_allowed_victim_engine_ids or [candidate.engine_id])
        min_days = engine.recycle_min_days_held or 0
        min_delta = engine.recycle_min_score_delta or 0.0

        eligible: list[tuple[float, float, int, OpenPosition]] = []
        for position in self._open_positions:
            victim_candidate = position.plan.candidate
            if victim_candidate.trade_direction != candidate.trade_direction:
                continue
            if victim_candidate.entry_timing_policy != candidate.entry_timing_policy:
                continue
            if victim_candidate.engine_id not in allowed_victims:
                continue
            if position.days_held < min_days:
                continue
            if candidate.score < (victim_candidate.score + min_delta):
                continue

            bar = self.store.get_bar(victim_candidate.symbol, date)
            if bar is None or bar.get("close") is None:
                continue
            close_value = float(bar["close"])
            if close_value <= 0:
                continue
            if engine.recycle_positive_pnl_only and close_value < position.entry_price:
                continue

            proceeds = close_value * position.shares_open
            if proceeds < shortfall:
                continue

            eligible.append((victim_candidate.score, proceeds, -position.days_held, position))

        if not eligible:
            return None

        eligible.sort(key=lambda item: (item[0], item[1], item[2]))
        return eligible[0][3]

    def _execute_same_day_recycle_exit(
        self,
        position: OpenPosition,
        date: dt.date,
        candidate: Candidate,
    ) -> bool:
        bar = self.store.get_bar(position.plan.candidate.symbol, date)
        trade = simulate_recycle_close_exit(
            position=position,
            bar=bar,
            current_date=date,
            config=self._build_effective_execution_config(position.plan.candidate),
        )
        if trade is None:
            return False

        self._closed_trades.append(trade)
        self._candidate_map[trade.trade_id] = position.plan.candidate
        self._realized_pnl += trade.net_pnl
        self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
        self._open_positions = [
            existing for existing in self._open_positions if existing.position_id != position.position_id
        ]
        logger.info(
            "same_day_recycle_exit",
            date=str(date),
            victim_symbol=position.plan.candidate.symbol,
            victim_engine=position.plan.candidate.engine_id,
            replacement_symbol=candidate.symbol,
            replacement_engine=candidate.engine_id,
        )
        return True

    def _force_close_all(self, date: dt.date, reason: str = "force_close") -> None:
        """Close all open positions (end of backtest or kill switch)."""
        for pos in list(self._open_positions):
            bar = self.store.get_bar(pos.plan.candidate.symbol, date)
            trade = simulate_kill_switch_exit(pos, bar, date, self.config.execution)
            self._closed_trades.append(trade)
            self._candidate_map[trade.trade_id] = pos.plan.candidate
            self._realized_pnl += trade.net_pnl
            self._cash += trade.net_pnl + (trade.entry_price * trade.shares)
        self._open_positions = []


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def _build_store(
    manifest: ExperimentManifest,
    config: BacktestConfig,
    split_name: str,
    snapshot_dir_override: str | None = None,
) -> SnapshotStore:
    from libs.common.config import get_settings

    s = get_settings()
    snapshot_dir = Path(snapshot_dir_override or s.parquet_dir) / config.dataset_snapshot_id

    # Resolve scoring function from config
    scoring_fn = None
    if config.signal.scoring_model == "pead":
        from libs.backtest.scoring import compute_pead_score
        from functools import partial

        scoring_fn = partial(
            compute_pead_score,
            reaction_threshold=config.signal.pead_reaction_threshold,
            volume_threshold=config.signal.pead_volume_threshold,
        )
    elif config.signal.scoring_model == "return_max_long_v1":
        from libs.backtest.scoring import compute_return_max_long_score

        scoring_fn = compute_return_max_long_score
    elif config.signal.scoring_model == "return_max_long_v2":
        from libs.backtest.scoring import compute_return_max_long_score_v2

        scoring_fn = compute_return_max_long_score_v2
    elif config.signal.scoring_model == "return_max_long_v3":
        from libs.backtest.scoring import compute_return_max_long_score_v3

        scoring_fn = compute_return_max_long_score_v3
    elif config.signal.scoring_model == "return_max_long_v4":
        from libs.backtest.scoring import compute_return_max_long_score_v4

        scoring_fn = compute_return_max_long_score_v4
    elif config.signal.scoring_model == "return_max_long_v5":
        from libs.backtest.scoring import compute_return_max_long_score_v5

        scoring_fn = compute_return_max_long_score_v5
    elif config.signal.scoring_model == "return_max_long_v6":
        from libs.backtest.scoring import compute_return_max_long_score_v6

        scoring_fn = compute_return_max_long_score_v6
    elif config.signal.scoring_model == "return_max_long_v7":
        from libs.backtest.scoring import compute_return_max_long_score_v7

        scoring_fn = compute_return_max_long_score_v7
    elif config.signal.scoring_model == "return_max_long_v8":
        from libs.backtest.scoring import compute_return_max_long_score_v8

        scoring_fn = compute_return_max_long_score_v8
    elif config.signal.scoring_model == "return_max_long_v9":
        from libs.backtest.scoring import compute_return_max_long_score_v9

        scoring_fn = compute_return_max_long_score_v9
    elif config.signal.scoring_model == "return_max_long_v9g":
        from libs.backtest.scoring import compute_return_max_long_score_v9g

        scoring_fn = compute_return_max_long_score_v9g
    elif config.signal.scoring_model == "return_max_long_v10":
        from libs.backtest.scoring import compute_return_max_long_score_v10

        scoring_fn = compute_return_max_long_score_v10
    elif config.signal.scoring_model == "patient_drift":
        from libs.backtest.scoring import compute_patient_drift_score

        scoring_fn = compute_patient_drift_score
    elif config.signal.scoring_model == "microstructure":
        from libs.backtest.scoring import compute_microstructure_score

        scoring_fn = compute_microstructure_score

    return SnapshotStore.load(
        snapshot_dir=snapshot_dir,
        split_name=split_name,
        oracle_url=s.stock_oracle_url,
        db_dsn=s.postgres_dsn,
        scoring_fn=scoring_fn,
    )


def _build_split_result_from_metrics(run_id: str, metrics: MetricsBundle) -> SplitResult:
    return SplitResult(
        run_id=run_id,
        trade_count=metrics.trade_count,
        profit_factor=metrics.profit_factor,
        total_return_pct=metrics.total_return_pct,
        win_rate=metrics.win_rate,
        max_drawdown_pct=metrics.max_drawdown_pct,
        sharpe_ratio=metrics.sharpe_ratio,
        monthly_win_rate=metrics.monthly_win_rate,
        equity_curve_r_squared=metrics.equity_curve_r_squared,
        avg_gross_exposure_pct=metrics.avg_gross_exposure_pct,
        avg_net_exposure_pct=metrics.avg_net_exposure_pct,
        days_in_market_pct=metrics.days_in_market_pct,
    )


def _effective_profit_factor(result: SplitResult) -> float | None:
    if result.profit_factor is not None:
        return result.profit_factor
    if result.trade_count > 0 and result.win_rate is not None and result.win_rate >= 0.999:
        return 3.0
    return None


def _build_walk_forward_aggregate(results: list[SplitResult]) -> WalkForwardAggregate:
    returns = [r.total_return_pct for r in results if r.total_return_pct is not None]
    profit_factors = [
        pf for pf in (_effective_profit_factor(r) for r in results) if pf is not None
    ]
    drawdowns = [r.max_drawdown_pct for r in results if r.max_drawdown_pct is not None]
    positive_folds = [
        r for r in results if r.total_return_pct is not None and r.total_return_pct > 0
    ]
    trade_counts = [float(r.trade_count) for r in results]
    win_rates = [r.win_rate for r in results if r.win_rate is not None]
    return WalkForwardAggregate(
        mean_return_pct=round(statistics.mean(returns), 2) if returns else None,
        median_return_pct=round(statistics.median(returns), 2) if returns else None,
        worst_return_pct=round(min(returns), 2) if returns else None,
        positive_fold_rate_pct=round(len(positive_folds) / len(results) * 100.0, 1) if results else None,
        mean_profit_factor=round(statistics.mean(profit_factors), 2) if profit_factors else None,
        mean_max_drawdown_pct=round(statistics.mean(drawdowns), 2) if drawdowns else None,
        mean_trade_count=round(statistics.mean(trade_counts), 1) if trade_counts else None,
        mean_win_rate=round(statistics.mean(win_rates), 4) if win_rates else None,
    )


def _build_walk_forward_gap_stats(
    train_results: list[SplitResult],
    test_results: list[SplitResult],
) -> WalkForwardGapStats:
    gaps = [
        (train.total_return_pct or 0.0) - (test.total_return_pct or 0.0)
        for train, test in zip(train_results, test_results, strict=False)
        if train.total_return_pct is not None and test.total_return_pct is not None
    ]
    test_returns = [r.total_return_pct for r in test_results if r.total_return_pct is not None]
    fold_return_cv = None
    if len(test_returns) >= 2:
        mean_ret = statistics.mean(test_returns)
        if abs(mean_ret) > 1e-9:
            fold_return_cv = round(statistics.stdev(test_returns) / abs(mean_ret), 3)
    return WalkForwardGapStats(
        mean_train_test_return_gap_pct=round(statistics.mean(gaps), 2) if gaps else None,
        worst_train_test_return_gap_pct=round(max(gaps), 2) if gaps else None,
        fold_return_cv=fold_return_cv,
    )


def _build_merged_snapshot_store(
    manifest: ExperimentManifest,
    config: BacktestConfig,
    snapshot_dir_override: str | None,
) -> SnapshotStore:
    stores: list[SnapshotStore] = []
    for split in ["train", "valid", "test"]:
        try:
            stores.append(_build_store(manifest, config, split, snapshot_dir_override=snapshot_dir_override))
        except FileNotFoundError:
            continue

    if not stores:
        raise FileNotFoundError("No snapshot splits found.")

    merged_candidates: dict[dt.date, dict[tuple[Any, ...], dict[str, Any]]] = defaultdict(dict)
    merged_bars: dict[str, dict[dt.date, dict[str, Any]]] = {}
    merged_macro: dict[dt.date, dict[str, Any]] = {}

    for store in stores:
        for exec_date in store.all_execution_dates():
            for candidate in store.get_candidates_for_date(exec_date):
                dedupe_key = (
                    candidate.get("event_id"),
                    candidate.get("symbol"),
                    candidate.get("execution_date"),
                    candidate.get("reaction_date"),
                )
                merged_candidates[exec_date].setdefault(dedupe_key, candidate)
        for symbol, bars in store._bars.items():
            merged_bars.setdefault(symbol, {}).update(bars)
        for macro_date, macro_values in store._macro.items():
            merged_macro.setdefault(macro_date, {}).update(macro_values)

    return SnapshotStore(
        candidates_by_exec_date={
            date: list(rows.values())
            for date, rows in merged_candidates.items()
        },
        bars_by_symbol_date=merged_bars,
        macro_by_date=merged_macro,
    )


def run_walk_forward(
    manifest: ExperimentManifest,
    config: BacktestConfig,
    snapshot_dir_override: str | None,
    initial_equity: float,
    output_root: str,
    train_days: int = 252,
    test_days: int = 63,
    step_days: int | None = None,
) -> WalkForwardSummary:
    """Run rolling walk-forward validation with explicit train/test folds."""
    step_days = step_days or test_days
    merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override)

    all_dates = merged_store.all_trading_days(include_reaction_dates=True)
    if not all_dates:
        raise RuntimeError("No trading days found in merged snapshot data for walk-forward run.")

    windows = generate_walk_forward_windows(
        all_dates,
        train_days=train_days,
        test_days=test_days,
        step_days=step_days,
    )
    if not windows:
        raise RuntimeError(
            f"Not enough data for walk-forward windows (need {train_days + test_days} days, have {len(all_dates)})."
        )

    wf_root = Path(output_root) / "walk_forward"
    wf_root.mkdir(parents=True, exist_ok=True)

    fold_results: list[WalkForwardFoldResult] = []
    train_split_results: list[SplitResult] = []
    test_split_results: list[SplitResult] = []

    for window in windows:
        fold_dir = wf_root / f"fold_{window.window_index:02d}"
        train_store = merged_store.slice_by_date_range(window.train_start, window.train_end)
        test_store = merged_store.slice_by_date_range(window.test_start, window.test_end)

        train_runner = BacktestRunner(
            manifest=manifest,
            config=config,
            store=train_store,
            initial_equity=initial_equity,
            split_name=f"wf_train_{window.window_index:02d}",
        )
        train_result = train_runner.run(output_root=fold_dir / "train")

        test_runner = BacktestRunner(
            manifest=manifest,
            config=config,
            store=test_store,
            initial_equity=initial_equity,
            split_name=f"wf_test_{window.window_index:02d}",
        )
        test_result = test_runner.run(output_root=fold_dir / "test")

        train_metrics = _build_split_result_from_metrics(train_result.run_id, train_result.metrics)
        test_metrics = _build_split_result_from_metrics(test_result.run_id, test_result.metrics)
        train_split_results.append(train_metrics)
        test_split_results.append(test_metrics)
        fold_results.append(
            WalkForwardFoldResult(
                fold_index=window.window_index,
                train_start=window.train_start,
                train_end=window.train_end,
                test_start=window.test_start,
                test_end=window.test_end,
                train_run_id=train_result.run_id,
                test_run_id=test_result.run_id,
                train_metrics=train_metrics,
                test_metrics=test_metrics,
            )
        )
        print(
            f"Fold {window.window_index:02d}: "
            f"train {window.train_start}→{window.train_end} "
            f"| test {window.test_start}→{window.test_end} "
            f"| train_ret={train_result.metrics.total_return_pct or 0:.2f}% "
            f"| test_ret={test_result.metrics.total_return_pct or 0:.2f}% "
            f"| test_trades={test_result.metrics.trade_count}"
        )

    summary = WalkForwardSummary(
        train_days=train_days,
        test_days=test_days,
        step_days=step_days,
        fold_count=len(fold_results),
        folds=fold_results,
        train_aggregate=_build_walk_forward_aggregate(train_split_results),
        test_aggregate=_build_walk_forward_aggregate(test_split_results),
        gap_stats=_build_walk_forward_gap_stats(train_split_results, test_split_results),
    )

    summary_path = wf_root / "walk_forward_summary.json"
    summary_path.write_text(summary.model_dump_json(indent=2))

    print(f"\n--- Walk-Forward Summary ({summary.fold_count} folds) ---")
    if summary.test_aggregate.mean_return_pct is not None:
        print(f"Test mean return: {summary.test_aggregate.mean_return_pct:.2f}%")
    if summary.test_aggregate.median_return_pct is not None:
        print(f"Test median return: {summary.test_aggregate.median_return_pct:.2f}%")
    if summary.test_aggregate.worst_return_pct is not None:
        print(f"Test worst return: {summary.test_aggregate.worst_return_pct:.2f}%")
    if summary.test_aggregate.positive_fold_rate_pct is not None:
        print(f"Test positive fold rate: {summary.test_aggregate.positive_fold_rate_pct:.1f}%")
    if summary.gap_stats.mean_train_test_return_gap_pct is not None:
        print(f"Mean train-test gap: {summary.gap_stats.mean_train_test_return_gap_pct:.2f}%")
    print(f"Summary written to: {summary_path}")

    return summary


def run_robustness_matrix(
    manifest: ExperimentManifest,
    config: BacktestConfig,
    snapshot_dir_override: str | None,
    initial_equity: float,
    output_root: str,
    horizons_days: list[int],
    step_days: int = 21,
) -> RobustnessMatrixSummary:
    """Run rolling horizon robustness validation over multiple start dates."""
    merged_store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override)
    all_dates = merged_store.all_trading_days(include_reaction_dates=True)
    if not all_dates:
        raise RuntimeError("No trading days found in merged snapshot data for robustness matrix.")

    windows_by_horizon = generate_robustness_windows(
        all_dates,
        horizons_days=horizons_days,
        step_days=step_days,
    )
    if not windows_by_horizon:
        raise RuntimeError(
            f"Not enough data for robustness windows (need {min(horizons_days)} days, have {len(all_dates)})."
        )

    rm_root = Path(output_root) / "robustness_matrix"
    rm_root.mkdir(parents=True, exist_ok=True)

    horizon_summaries: list[RobustnessHorizonSummary] = []
    overall_results: list[SplitResult] = []

    for horizon_days in sorted(windows_by_horizon):
        window_results: list[SplitResult] = []
        for window in windows_by_horizon[horizon_days]:
            window_store = merged_store.slice_by_date_range(window.start, window.end)
            runner = BacktestRunner(
                manifest=manifest,
                config=config,
                store=window_store,
                initial_equity=initial_equity,
                split_name=f"rm_{horizon_days}_{window.window_index:02d}",
            )
            result = runner.run(output_root=None)
            split_result = _build_split_result_from_metrics(result.run_id, result.metrics)
            window_results.append(split_result)
            overall_results.append(split_result)

        aggregate = _build_walk_forward_aggregate(window_results)
        horizon_summaries.append(
            RobustnessHorizonSummary(
                horizon_days=horizon_days,
                window_count=len(window_results),
                mean_return_pct=aggregate.mean_return_pct,
                median_return_pct=aggregate.median_return_pct,
                worst_return_pct=aggregate.worst_return_pct,
                positive_window_rate_pct=aggregate.positive_fold_rate_pct,
                mean_max_drawdown_pct=aggregate.mean_max_drawdown_pct,
            )
        )

    overall_positive = [
        result for result in overall_results
        if result.total_return_pct is not None and result.total_return_pct > 0
    ]
    overall_returns = [result.total_return_pct for result in overall_results if result.total_return_pct is not None]
    summary = RobustnessMatrixSummary(
        horizons_days=sorted(windows_by_horizon),
        step_days=step_days,
        overall_window_count=len(overall_results),
        overall_positive_window_rate_pct=(
            round(len(overall_positive) / len(overall_results) * 100.0, 1)
            if overall_results else None
        ),
        overall_worst_return_pct=round(min(overall_returns), 2) if overall_returns else None,
        horizon_summaries=horizon_summaries,
    )

    summary_path = rm_root / "robustness_matrix_summary.json"
    summary_path.write_text(summary.model_dump_json(indent=2))
    print("\n--- Robustness Matrix Summary ---")
    print(f"Horizons: {', '.join(str(h) for h in summary.horizons_days)}")
    print(f"Window count: {summary.overall_window_count}")
    if summary.overall_positive_window_rate_pct is not None:
        print(f"Positive window rate: {summary.overall_positive_window_rate_pct:.1f}%")
    if summary.overall_worst_return_pct is not None:
        print(f"Worst window return: {summary.overall_worst_return_pct:.2f}%")
    print(f"Summary written to: {summary_path}")
    return summary


def main() -> None:
    parser = argparse.ArgumentParser(description="ACE-F Backtester")
    parser.add_argument("--manifest", required=True, help="Path to experiment manifest JSON")
    parser.add_argument("--snapshot-id", help="Override dataset_snapshot_id")
    parser.add_argument("--snapshot-dir", help="Override snapshot root directory (default: data/parquet/)")
    parser.add_argument("--split", default="train", help="Split name (train/valid/test)")
    parser.add_argument("--output-root", default="./runs", help="Output root directory")
    parser.add_argument("--initial-equity", type=float, default=100_000.0)
    parser.add_argument("--config-root", default=".", help="Root dir for resolving config paths")
    parser.add_argument(
        "--walk-forward",
        action="store_true",
        help="Run walk-forward cross-validation instead of single backtest",
    )
    parser.add_argument("--wf-train-days", type=int, default=252, help="Walk-forward train window (trading days)")
    parser.add_argument("--wf-test-days", type=int, default=63, help="Walk-forward test window (trading days)")
    parser.add_argument("--wf-step-days", type=int, default=None, help="Walk-forward step size (default: wf-test-days)")
    parser.add_argument(
        "--robustness-matrix",
        action="store_true",
        help="Run rolling horizon robustness matrix instead of single backtest",
    )
    parser.add_argument(
        "--rm-horizons",
        default="21,63,126,252,504",
        help="Comma-separated robustness horizons in trading days",
    )
    parser.add_argument("--rm-step-days", type=int, default=21, help="Robustness matrix step size (trading days)")
    parser.add_argument("--mode", choices=["research", "live"], default=None,
                        help="Backtest mode: research (kill switch resets) or live (permanent)")
    args = parser.parse_args()

    manifest = load_manifest(args.manifest)
    config = resolve_config(manifest, config_root=args.config_root, snapshot_id_override=args.snapshot_id)

    if args.mode:
        config.risk.backtest_mode = args.mode

    if args.walk_forward and args.robustness_matrix:
        raise SystemExit("Use either --walk-forward or --robustness-matrix, not both.")

    if args.walk_forward:
        run_walk_forward(
            manifest=manifest,
            config=config,
            snapshot_dir_override=args.snapshot_dir,
            initial_equity=args.initial_equity,
            output_root=args.output_root,
            train_days=args.wf_train_days,
            test_days=args.wf_test_days,
            step_days=args.wf_step_days,
        )
    elif args.robustness_matrix:
        horizons = [int(part.strip()) for part in args.rm_horizons.split(",") if part.strip()]
        run_robustness_matrix(
            manifest=manifest,
            config=config,
            snapshot_dir_override=args.snapshot_dir,
            initial_equity=args.initial_equity,
            output_root=args.output_root,
            horizons_days=horizons,
            step_days=args.rm_step_days,
        )
    else:
        store = _build_store(manifest, config, args.split, snapshot_dir_override=args.snapshot_dir)
        runner = BacktestRunner(
            manifest=manifest,
            config=config,
            store=store,
            initial_equity=args.initial_equity,
            split_name=args.split,
        )
        result = runner.run(output_root=args.output_root)
        print(f"Run complete: {result.run_id}")
        print(f"Trades: {result.metrics.trade_count}")
        if result.metrics.total_return_pct is not None:
            print(f"Total return: {result.metrics.total_return_pct:.2f}%")

        # Print SQS score
        from libs.backtest.tracker import compute_sqs
        sqs_score, sqs_breakdown = compute_sqs(result.metrics)
        print(f"SQS: {sqs_score} ({', '.join(f'{k}={v}' for k, v in sqs_breakdown.items())})")


if __name__ == "__main__":
    main()
