"""Shared momentum research helpers for WFV and holdout evaluation."""
from __future__ import annotations

import gzip
import hashlib
import json
import pickle
import statistics
import sys
import uuid
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
from typing import Any

from libs.backtest.domain import SplitResult, WalkForwardSummary
from libs.backtest.tracker import compute_wfqs_v2
from libs.common.config import get_settings
from libs.intraday.cache import DailyBarCache, IntradayCache
from libs.intraday.domain import IntradayConfig, IntradayMetrics, StrategyParams
from libs.intraday.metrics import compute_metrics
from libs.intraday.catalyst import (
    AttentionEventCache,
    FilingEventCache,
    fetch_attention_features_bulk,
    fetch_filing_event_features_bulk,
)
from libs.intraday.screener import (
    fetch_daily_bars_bulk,
    fetch_intraday_bulk,
    momentum_intraday_first_candidates,
    momentum_pre_screen_candidates,
    pre_screen_candidates,
    resolve_universe,
)
from libs.intraday.simulator import run_simulation
from libs.oracle_client import OracleClient

from apps.intraday_bt.orb_research import (
    build_walk_forward_summary,
    generate_walk_forward_windows,
)
from apps.intraday_bt.run import (
    _fetch_vix_by_day,
    _load_ticker_sectors_with_oracle,
    _make_progress_bar,
    _augment_momentum_seed_candidates_with_liquid_overlay,
    _merge_momentum_attention_features,
    _merge_momentum_event_features,
    _momentum_candidate_event_pairs,
    _momentum_candidate_event_tickers,
    _momentum_intraday_seed_candidates,
    _momentum_preliminary_candidates,
    _momentum_strategy_uses_candidate_stage_catalyst,
    _momentum_strategy_uses_attention,
    _momentum_strategy_uses_catalyst,
    _momentum_enrichment_for_days,
    _momentum_uses_historical_intraday_first,
    _momentum_strategy_uses_daily_enrichment,
    _momentum_strategy_uses_vix,
    get_trading_days,
)

_MOMENTUM_RESEARCH_SNAPSHOT_VERSION = 7


@dataclass
class MomentumResearchContext:
    config: IntradayConfig
    tickers: list[str]
    ticker_sectors: dict[str, str]
    trading_days: list[str]
    daily_bars: dict[str, list[dict]]
    all_intraday: dict[str, dict[str, list[dict]]]
    daily_enrichment: dict[str, dict[str, dict]] | None
    vix_by_day: dict[str, float] | None
    candidates: dict[str, list[str]]
    candidate_pairs: int
    research_snapshot_key: str | None = None


class MomentumResearchSnapshotStore:
    """Disk snapshot for expensive momentum research context preparation."""

    def __init__(self, root: str | Path) -> None:
        self.root = Path(root)

    def _path(self, key: str) -> Path:
        return self.root / key[:2] / f"{key}.pkl.gz"

    @classmethod
    def build_key(
        cls,
        config: IntradayConfig,
        *,
        start_date: str,
        end_date: str,
        tickers: list[str],
        trading_days: list[str],
    ) -> str:
        strategy = config.strategy
        payload = {
            "version": _MOMENTUM_RESEARCH_SNAPSHOT_VERSION,
            "strategy_mode": config.strategy_mode,
            "start_date": start_date,
            "end_date": end_date,
            "universe": config.universe.model_dump(mode="json"),
            "pre_screen_threshold": config.backtest.pre_screen_threshold,
            # Research snapshots cache the fetched intraday seed set and any
            # candidate-scoped enrichment, so the full strategy is the safest
            # invalidation boundary. This prevents stale snapshots when new
            # seed-overlay / liquid-largecap controls are introduced.
            "strategy": config.strategy.model_dump(mode="json"),
            "uses_daily_enrichment": _momentum_strategy_uses_daily_enrichment(strategy),
            "uses_catalyst": _momentum_strategy_uses_catalyst(strategy),
            "uses_attention": _momentum_strategy_uses_attention(strategy),
            "uses_vix": _momentum_strategy_uses_vix(strategy),
            "tickers": tickers,
            "trading_days": trading_days,
        }
        blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
        return hashlib.sha1(blob.encode("utf-8")).hexdigest()

    def load(self, key: str) -> dict[str, Any] | None:
        path = self._path(key)
        if not path.exists():
            return None
        try:
            with gzip.open(path, "rb") as fh:
                payload = pickle.load(fh)
        except Exception:
            path.unlink(missing_ok=True)
            return None
        if not isinstance(payload, dict):
            path.unlink(missing_ok=True)
            return None
        if payload.get("version") != _MOMENTUM_RESEARCH_SNAPSHOT_VERSION:
            path.unlink(missing_ok=True)
            return None
        if payload.get("key") != key:
            path.unlink(missing_ok=True)
            return None
        required = {
            "tickers",
            "trading_days",
            "daily_bars",
            "all_intraday",
            "daily_enrichment",
            "vix_by_day",
            "candidates",
            "candidate_pairs",
        }
        if not required.issubset(set(payload)):
            path.unlink(missing_ok=True)
            return None
        return payload

    def save(self, key: str, payload: dict[str, Any]) -> Path:
        path = self._path(key)
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        record = dict(payload)
        record["version"] = _MOMENTUM_RESEARCH_SNAPSHOT_VERSION
        record["key"] = key
        with gzip.open(tmp, "wb", compresslevel=3) as fh:
            pickle.dump(record, fh, protocol=pickle.HIGHEST_PROTOCOL)
        tmp.replace(path)
        return path


def intraday_metrics_to_momentum_split_result(
    metrics: IntradayMetrics,
    strategy: StrategyParams,
) -> SplitResult:
    """Adapt momentum metrics into the generic split schema used by WFV scorers."""
    days_in_market_pct = None
    if metrics.trading_days > 0:
        days_in_market_pct = round(metrics.days_with_trades / metrics.trading_days * 100.0, 1)

    total_return_pct = metrics.total_return_pct * 100.0 if metrics.total_return_pct is not None else None
    annualized_return_pct = (
        metrics.annualized_return_pct * 100.0 if metrics.annualized_return_pct is not None else None
    )
    max_drawdown_pct = (
        abs(metrics.max_drawdown_pct) * 100.0 if metrics.max_drawdown_pct is not None else None
    )

    # The momentum engine deploys the day's basket across the full book using
    # equal-weight slots, so exposure is best approximated as fully invested on
    # active days instead of reusing ORB's position-cap heuristic.
    gross_proxy = 100.0 if metrics.days_with_trades > 0 and strategy.top_n > 0 else 0.0

    return SplitResult(
        run_id=metrics.run_id,
        trade_count=metrics.total_trades,
        profit_factor=metrics.profit_factor,
        total_return_pct=total_return_pct,
        annualized_return_pct=annualized_return_pct,
        win_rate=metrics.win_rate,
        max_drawdown_pct=max_drawdown_pct,
        sharpe_ratio=metrics.sharpe_ratio,
        monthly_win_rate=None,
        equity_curve_r_squared=None,
        avg_gross_exposure_pct=round(gross_proxy, 1),
        avg_net_exposure_pct=round(gross_proxy, 1),
        days_in_market_pct=days_in_market_pct,
    )


def _wfv_score_payload(
    summary: WalkForwardSummary,
    holdout_metrics: IntradayMetrics,
) -> dict[str, float | None]:
    wfqs_score, _ = compute_wfqs_v2(summary)
    mean_test_return = summary.test_aggregate.mean_return_pct
    positive_fold_rate = summary.test_aggregate.positive_fold_rate_pct
    worst_fold_return = summary.test_aggregate.worst_return_pct
    holdout_return = (
        (holdout_metrics.total_return_pct or 0.0) * 100.0
        if holdout_metrics.total_return_pct is not None
        else None
    )
    holdout_sharpe = holdout_metrics.sharpe_ratio
    holdout_loss_containment = holdout_metrics.loss_containment_score
    holdout_avg_loss_day = (
        (holdout_metrics.avg_loss_day_pct or 0.0) * 100.0
        if holdout_metrics.avg_loss_day_pct is not None
        else None
    )
    holdout_tail_loss = (
        (holdout_metrics.tail_loss_20_pct or 0.0) * 100.0
        if holdout_metrics.tail_loss_20_pct is not None
        else None
    )
    # Conservative ranking: prefer stable WFV first, then holdout confirmation.
    selection_score = (
        (wfqs_score or 0.0) * 0.55
        + (mean_test_return or 0.0) * 1.80
        + (positive_fold_rate or 0.0) * 0.18
        + max(worst_fold_return or 0.0, -25.0) * 0.35
        + (holdout_return or 0.0) * 1.25
        + (holdout_sharpe or 0.0) * 3.0
        + (holdout_loss_containment or 0.0) * 0.12
    )
    return {
        "selection_score": round(selection_score, 2),
        "wfqs_v2": None if wfqs_score is None else round(wfqs_score, 2),
        "mean_test_return_pct": None if mean_test_return is None else round(mean_test_return, 2),
        "positive_fold_rate_pct": None if positive_fold_rate is None else round(positive_fold_rate, 1),
        "worst_fold_return_pct": None if worst_fold_return is None else round(worst_fold_return, 2),
        "holdout_return_pct": None if holdout_return is None else round(holdout_return, 2),
        "holdout_sharpe": None if holdout_sharpe is None else round(holdout_sharpe, 2),
        "holdout_avg_loss_day_pct": None if holdout_avg_loss_day is None else round(holdout_avg_loss_day, 2),
        "holdout_tail_loss_20_pct": None if holdout_tail_loss is None else round(holdout_tail_loss, 2),
        "holdout_loss_containment_score": (
            None if holdout_loss_containment is None else round(holdout_loss_containment, 2)
        ),
    }


def group_trading_days_by_quarter(trading_days: list[str]) -> list[tuple[str, list[str]]]:
    """Group prepared trading days into calendar quarters preserving order."""
    grouped: dict[str, list[str]] = {}
    labels: list[str] = []
    for day in trading_days:
        day_obj = date.fromisoformat(day)
        quarter = ((day_obj.month - 1) // 3) + 1
        label = f"{day_obj.year}Q{quarter}"
        if label not in grouped:
            grouped[label] = []
            labels.append(label)
        grouped[label].append(day)
    return [(label, grouped[label]) for label in labels]


def _quarterly_score_payload(
    wfv_score: dict[str, float | None],
    quarter_metrics: list[tuple[str, IntradayMetrics]],
) -> tuple[dict[str, float | None], list[dict[str, Any]]]:
    """Blend walk-forward quality with 2025 quarterly stability."""
    quarter_rows: list[dict[str, Any]] = []
    quarter_returns: list[float] = []
    quarter_sharpes: list[float] = []
    quarter_drawdowns: list[float] = []
    quarter_loss_scores: list[float] = []
    quarter_avg_loss_days: list[float] = []
    quarter_tail_losses: list[float] = []

    for label, metrics in quarter_metrics:
        return_pct = (
            None
            if metrics.total_return_pct is None
            else round(metrics.total_return_pct * 100.0, 2)
        )
        sharpe = None if metrics.sharpe_ratio is None else round(metrics.sharpe_ratio, 2)
        drawdown_pct = (
            None
            if metrics.max_drawdown_pct is None
            else round(abs(metrics.max_drawdown_pct) * 100.0, 2)
        )
        avg_loss_day_pct = (
            None
            if metrics.avg_loss_day_pct is None
            else round(metrics.avg_loss_day_pct * 100.0, 2)
        )
        tail_loss_20_pct = (
            None
            if metrics.tail_loss_20_pct is None
            else round(metrics.tail_loss_20_pct * 100.0, 2)
        )
        loss_containment_score = (
            None
            if metrics.loss_containment_score is None
            else round(metrics.loss_containment_score, 2)
        )
        quarter_rows.append(
            {
                "quarter": label,
                "total_return_pct": return_pct,
                "sharpe_ratio": sharpe,
                "max_drawdown_pct": drawdown_pct,
                "avg_loss_day_pct": avg_loss_day_pct,
                "tail_loss_20_pct": tail_loss_20_pct,
                "loss_containment_score": loss_containment_score,
                "trades": metrics.total_trades,
            }
        )
        if return_pct is not None:
            quarter_returns.append(return_pct)
        if sharpe is not None:
            quarter_sharpes.append(sharpe)
        if drawdown_pct is not None:
            quarter_drawdowns.append(drawdown_pct)
        if loss_containment_score is not None:
            quarter_loss_scores.append(loss_containment_score)
        if avg_loss_day_pct is not None:
            quarter_avg_loss_days.append(avg_loss_day_pct)
        if tail_loss_20_pct is not None:
            quarter_tail_losses.append(tail_loss_20_pct)

    mean_return = statistics.mean(quarter_returns) if quarter_returns else None
    positive_rate = (
        sum(1 for value in quarter_returns if value > 0) / len(quarter_returns) * 100.0
        if quarter_returns
        else None
    )
    worst_return = min(quarter_returns) if quarter_returns else None
    return_stdev = statistics.pstdev(quarter_returns) if len(quarter_returns) > 1 else 0.0
    mean_sharpe = statistics.mean(quarter_sharpes) if quarter_sharpes else None
    mean_drawdown = statistics.mean(quarter_drawdowns) if quarter_drawdowns else None
    mean_loss_containment = statistics.mean(quarter_loss_scores) if quarter_loss_scores else None
    mean_avg_loss_day = statistics.mean(quarter_avg_loss_days) if quarter_avg_loss_days else None
    worst_tail_loss = min(quarter_tail_losses) if quarter_tail_losses else None

    quarterly_selection_score = (
        (wfv_score.get("selection_score") or 0.0) * 0.35
        + (mean_return or 0.0) * 2.60
        + (positive_rate or 0.0) * 0.45
        + max(worst_return or 0.0, -25.0) * 1.60
        - return_stdev * 1.10
        + (wfv_score.get("holdout_return_pct") or 0.0) * 0.45
        + (wfv_score.get("holdout_sharpe") or 0.0) * 1.80
        + (mean_loss_containment or 0.0) * 0.12
    )

    score = dict(wfv_score)
    score.update(
        {
            "quarter_mean_return_pct": None if mean_return is None else round(mean_return, 2),
            "quarter_positive_rate_pct": None if positive_rate is None else round(positive_rate, 1),
            "quarter_worst_return_pct": None if worst_return is None else round(worst_return, 2),
            "quarter_return_stdev_pct": None if mean_return is None else round(return_stdev, 2),
            "quarter_mean_sharpe": None if mean_sharpe is None else round(mean_sharpe, 2),
            "quarter_mean_max_drawdown_pct": (
                None if mean_drawdown is None else round(mean_drawdown, 2)
            ),
            "quarter_mean_avg_loss_day_pct": (
                None if mean_avg_loss_day is None else round(mean_avg_loss_day, 2)
            ),
            "quarter_worst_tail_loss_20_pct": (
                None if worst_tail_loss is None else round(worst_tail_loss, 2)
            ),
            "quarter_mean_loss_containment_score": (
                None if mean_loss_containment is None else round(mean_loss_containment, 2)
            ),
            "quarterly_selection_score": round(quarterly_selection_score, 2),
        }
    )
    return score, quarter_rows


async def build_momentum_research_context(
    config: IntradayConfig,
    start_date: str,
    end_date: str,
    client: OracleClient,
    *,
    print_progress: bool = False,
    daily_concurrency: int = 12,
    intraday_concurrency: int = 8,
) -> MomentumResearchContext:
    """Prepare one full momentum research context for repeated parameter evaluation."""
    if config.strategy_mode != "momentum":
        raise ValueError("Momentum research context requires strategy_mode='momentum'")

    snapshot_store = (
        MomentumResearchSnapshotStore(Path(config.cache.dir).with_name("momentum_research"))
        if config.cache.enabled else None
    )
    cache = IntradayCache(config.cache.dir) if config.cache.enabled else None
    daily_cache = (
        DailyBarCache(str(Path(config.cache.dir).with_name("daily")))
        if config.cache.enabled else None
    )
    event_cache = (
        FilingEventCache(str(Path(config.cache.dir).with_name("momentum_catalyst")))
        if config.cache.enabled else None
    )
    attention_cache = (
        AttentionEventCache(str(Path(config.cache.dir).with_name("momentum_attention")))
        if config.cache.enabled else None
    )

    tickers = await resolve_universe(config.universe, client)
    trading_days = await get_trading_days(client, start_date, end_date, lookback=0)
    if not trading_days:
        raise ValueError(f"No trading days resolved for {start_date} → {end_date}")

    snapshot_key = None
    if snapshot_store is not None:
        snapshot_key = snapshot_store.build_key(
            config,
            start_date=start_date,
            end_date=end_date,
            tickers=tickers,
            trading_days=trading_days,
        )
        snapshot = snapshot_store.load(snapshot_key)
        if snapshot is not None:
            if print_progress:
                print(
                    "  Momentum research snapshot hit: "
                    f"{len(snapshot['tickers'])} tickers, {len(snapshot['trading_days'])} days"
                )
            return MomentumResearchContext(
                config=config,
                tickers=list(snapshot["tickers"]),
                ticker_sectors=await _load_ticker_sectors_with_oracle(
                    list(snapshot["tickers"]),
                    client,
                ),
                trading_days=list(snapshot["trading_days"]),
                daily_bars=dict(snapshot["daily_bars"]),
                all_intraday=dict(snapshot["all_intraday"]),
                daily_enrichment=snapshot["daily_enrichment"],
                vix_by_day=snapshot["vix_by_day"],
                candidates=dict(snapshot["candidates"]),
                candidate_pairs=int(snapshot["candidate_pairs"]),
                research_snapshot_key=snapshot_key,
            )

    if _momentum_strategy_uses_daily_enrichment(config.strategy):
        daily_fetch_start = (date.fromisoformat(trading_days[0]) - timedelta(days=90)).isoformat()
    else:
        daily_fetch_start = trading_days[0]

    def _daily_progress(done: int, total: int) -> None:
        if not print_progress:
            return
        sys.stdout.write(f"\r  Daily: {_make_progress_bar(done, total)}")
        sys.stdout.flush()

    daily_bars = await fetch_daily_bars_bulk(
        tickers,
        daily_fetch_start,
        trading_days[-1],
        client,
        cache=daily_cache,
        intraday_cache_fallback=cache,
        prefer_intraday_fallback=True,
        skip_oracle_when_unhealthy=True,
        concurrency=daily_concurrency,
        progress_callback=_daily_progress if print_progress else None,
    )
    if print_progress:
        print(f"\n  Daily loaded: {len(daily_bars)}/{len(tickers)}")

    daily_enrichment = None
    if _momentum_strategy_uses_daily_enrichment(config.strategy):
        if print_progress:
            print("  Computing momentum enrichment...")
        daily_enrichment = _momentum_enrichment_for_days(daily_bars, trading_days)

    vix_by_day = None
    if _momentum_strategy_uses_vix(config.strategy):
        if print_progress:
            print("  Fetching VIX regime series...")
        vix_by_day = await _fetch_vix_by_day(client, trading_days)

    if (
        daily_enrichment is not None
        and (_momentum_strategy_uses_catalyst(config.strategy) or _momentum_strategy_uses_attention(config.strategy))
    ):
        preliminary_candidates = _momentum_intraday_seed_candidates(
            daily_bars,
            trading_days,
            daily_enrichment,
            config.strategy,
            default_threshold=config.backtest.pre_screen_threshold,
            use_signal_features=False,
        )
        if _momentum_strategy_uses_catalyst(config.strategy):
            if _momentum_strategy_uses_candidate_stage_catalyst(config.strategy):
                event_tickers = sorted(daily_bars.keys())
            else:
                event_tickers = _momentum_candidate_event_tickers(preliminary_candidates)
            if print_progress:
                print(f"  Fetching momentum catalysts for {len(event_tickers)} tickers...")
            event_features = await fetch_filing_event_features_bulk(
                event_tickers,
                trading_days[0],
                trading_days[-1],
                client,
                cache=event_cache,
                concurrency=8,
            )
            _merge_momentum_event_features(daily_enrichment, event_features)
        if _momentum_strategy_uses_attention(config.strategy):
            attention_pairs = _momentum_candidate_event_pairs(
                preliminary_candidates,
                daily_enrichment,
                config.strategy,
            )
            if print_progress:
                print(f"  Fetching momentum attention for {len(attention_pairs)} ticker-days...")
            attention_features = await fetch_attention_features_bulk(
                attention_pairs,
                client,
                cache=attention_cache,
                concurrency=8,
            )
            _merge_momentum_attention_features(daily_enrichment, attention_features)

    def _intraday_progress(done: int, total: int, hits: int, calls: int) -> None:
        if not print_progress:
            return
        if completed := (done == 0 and calls == 0 and total > 0):
            _ = completed
            sys.stdout.write("\n")
            sys.stdout.flush()
        sys.stdout.write(
            f"\r  Intraday: {_make_progress_bar(done, total)}  cache:{hits}  api:{calls}"
        )
        sys.stdout.flush()

    seed_candidates = _momentum_intraday_seed_candidates(
        daily_bars,
        trading_days,
        daily_enrichment or {},
        config.strategy,
        default_threshold=config.backtest.pre_screen_threshold,
        use_signal_features=True,
    )
    seed_candidates, _ = _augment_momentum_seed_candidates_with_liquid_overlay(
        seed_candidates,
        daily_bars,
        trading_days,
        daily_enrichment or {},
        config.strategy,
    )

    seed_pairs = sum(len(v) for v in seed_candidates.values())
    if print_progress:
        label = "seed shortlist" if _momentum_uses_historical_intraday_first(config.strategy) else "pre-screened"
        print(f"  {label.capitalize()}: {seed_pairs} ticker-day pairs across {len(seed_candidates)} days")

    all_intraday = await fetch_intraday_bulk(
        seed_candidates,
        client,
        cache,
        skip_oracle_when_unhealthy=True,
        concurrency=intraday_concurrency,
        progress_callback=_intraday_progress if print_progress else None,
    )
    if print_progress:
        print(f"\n  Intraday loaded: {len(all_intraday)} days")

    ticker_sectors = await _load_ticker_sectors_with_oracle(tickers, client)

    if _momentum_uses_historical_intraday_first(config.strategy):
        candidates = momentum_intraday_first_candidates(
            all_intraday,
            trading_days,
            config.strategy,
            daily_enrichment=daily_enrichment,
            ticker_sectors=ticker_sectors,
            max_per_day=config.strategy.candidate_final_max_per_day,
        )
    else:
        candidates = momentum_pre_screen_candidates(
            daily_bars,
            trading_days,
            daily_enrichment or {},
            threshold=config.backtest.pre_screen_threshold,
            max_per_day=config.strategy.candidate_final_max_per_day,
            strategy=config.strategy,
        )
    candidate_pairs = sum(len(v) for v in candidates.values())
    if print_progress:
        print(f"  Final candidates: {candidate_pairs} ticker-day pairs across {len(candidates)} days")

    context = MomentumResearchContext(
        config=config,
        tickers=tickers,
        ticker_sectors=ticker_sectors,
        trading_days=trading_days,
        daily_bars=daily_bars,
        all_intraday=all_intraday,
        daily_enrichment=daily_enrichment,
        vix_by_day=vix_by_day,
        candidates=candidates,
        candidate_pairs=candidate_pairs,
        research_snapshot_key=snapshot_key,
    )
    if snapshot_store is not None and snapshot_key is not None:
        snapshot_path = snapshot_store.save(
            snapshot_key,
            {
                "tickers": tickers,
                "trading_days": trading_days,
                "daily_bars": daily_bars,
                "all_intraday": all_intraday,
                "daily_enrichment": daily_enrichment,
                "vix_by_day": vix_by_day,
                "candidates": candidates,
                "candidate_pairs": candidate_pairs,
            },
        )
        if print_progress:
            print(f"  Momentum research snapshot saved: {snapshot_path}")
    return context


def build_momentum_strategy(
    base_config: IntradayConfig,
    overrides: dict[str, Any] | None = None,
) -> StrategyParams:
    if not overrides:
        return base_config.strategy
    base = base_config.strategy.model_dump(mode="json")
    base.update(overrides)
    return StrategyParams.model_validate(base)


def _normalize_momentum_research_strategy(strategy: StrategyParams) -> StrategyParams:
    """Research runs use daily-reset simple sizing by default.

    This keeps 2025 WFV / quarter comparisons path-independent and prevents
    late-period equity changes from dominating candidate selection.
    """
    return strategy.model_copy(
        update={
            "compound_returns": False,
            "daily_budget_reset": True,
        }
    )


def simulate_momentum_params(
    context: MomentumResearchContext,
    strategy: StrategyParams,
    trading_days: list[str],
    *,
    run_id: str = "",
) -> tuple[list[Any], IntradayMetrics]:
    """Evaluate one momentum parameter set on a subset of prepared trading days."""
    if _momentum_uses_historical_intraday_first(strategy):
        strategy_candidates = momentum_intraday_first_candidates(
            {
                day: context.all_intraday.get(day, {})
                for day in trading_days
            },
            trading_days,
            strategy,
            daily_enrichment=context.daily_enrichment,
            ticker_sectors=context.ticker_sectors,
            max_per_day=strategy.candidate_final_max_per_day,
        )
    else:
        strategy_candidates = momentum_pre_screen_candidates(
            context.daily_bars,
            trading_days,
            context.daily_enrichment or {},
            threshold=context.config.backtest.pre_screen_threshold,
            max_per_day=strategy.candidate_final_max_per_day,
            strategy=strategy,
        )
    subset_intraday = {
        day: {
            ticker: context.all_intraday.get(day, {}).get(ticker)
            for ticker in strategy_candidates.get(day, [])
            if context.all_intraday.get(day, {}).get(ticker)
        }
        for day in trading_days
        if strategy_candidates.get(day)
    }
    run_config = context.config.model_copy(update={"strategy": strategy})
    day_results = run_simulation(
        subset_intraday,
        trading_days,
        strategy,
        daily_enrichment=context.daily_enrichment,
        vix_by_day=context.vix_by_day,
        ticker_sectors=context.ticker_sectors,
    )
    metrics = compute_metrics(day_results, run_config, run_id=run_id or str(uuid.uuid4())[:8])
    return day_results, metrics


def evaluate_momentum_wfv_candidate(
    context_2025: MomentumResearchContext,
    strategy: StrategyParams,
    *,
    train_days: int,
    test_days: int,
    step_days: int,
    holdout_context: MomentumResearchContext | None = None,
) -> dict[str, Any]:
    """Evaluate one strategy over rolling 2025 windows and optional holdout."""
    strategy = _normalize_momentum_research_strategy(strategy)
    windows = generate_walk_forward_windows(
        context_2025.trading_days,
        train_days=train_days,
        test_days=test_days,
        step_days=step_days,
    )
    folds: list[dict[str, Any]] = []
    for idx, (train_window, test_window) in enumerate(windows, start=1):
        _, train_metrics = simulate_momentum_params(
            context_2025,
            strategy,
            train_window,
            run_id=f"mwf_tr_{idx:02d}",
        )
        _, test_metrics = simulate_momentum_params(
            context_2025,
            strategy,
            test_window,
            run_id=f"mwf_te_{idx:02d}",
        )
        folds.append(
            {
                "train_start": train_window[0],
                "train_end": train_window[-1],
                "test_start": test_window[0],
                "test_end": test_window[-1],
                "train_result": intraday_metrics_to_momentum_split_result(train_metrics, strategy),
                "test_result": intraday_metrics_to_momentum_split_result(test_metrics, strategy),
            }
        )

    summary = build_walk_forward_summary(
        folds,
        train_days=train_days,
        test_days=test_days,
        step_days=step_days,
    )

    holdout_metrics = None
    holdout_result = None
    if holdout_context is not None:
        _, holdout_metrics = simulate_momentum_params(
            holdout_context,
            strategy,
            holdout_context.trading_days,
            run_id="mwf_holdout",
        )
        holdout_result = intraday_metrics_to_momentum_split_result(holdout_metrics, strategy)

    score = _wfv_score_payload(
        summary,
        holdout_metrics or IntradayMetrics(run_id="holdout"),
    )
    return {
        "strategy": strategy.model_dump(mode="json"),
        "walk_forward_summary": summary.model_dump(mode="json"),
        "holdout_metrics": None if holdout_metrics is None else holdout_metrics.model_dump(mode="json"),
        "holdout_result": None if holdout_result is None else holdout_result.model_dump(mode="json"),
        "score": score,
    }


def evaluate_momentum_quarterly_candidate(
    context_2025: MomentumResearchContext,
    strategy: StrategyParams,
    *,
    train_days: int,
    test_days: int,
    step_days: int,
    holdout_context: MomentumResearchContext | None = None,
) -> dict[str, Any]:
    """Evaluate one candidate using WFV plus 2025 quarter-by-quarter robustness."""
    strategy = _normalize_momentum_research_strategy(strategy)
    payload = evaluate_momentum_wfv_candidate(
        context_2025,
        strategy,
        train_days=train_days,
        test_days=test_days,
        step_days=step_days,
        holdout_context=holdout_context,
    )

    quarter_metrics: list[tuple[str, IntradayMetrics]] = []
    for label, quarter_days in group_trading_days_by_quarter(context_2025.trading_days):
        _, metrics = simulate_momentum_params(
            context_2025,
            strategy,
            quarter_days,
            run_id=f"mq_{label.lower()}",
        )
        quarter_metrics.append((label, metrics))

    score, quarter_rows = _quarterly_score_payload(
        payload["score"],
        quarter_metrics,
    )
    payload["score"] = score
    payload["quarter_metrics"] = quarter_rows
    return payload


def summarize_fold_returns(summary: WalkForwardSummary) -> dict[str, float | None]:
    test_returns = [
        fold.test_metrics.total_return_pct
        for fold in summary.folds
        if fold.test_metrics.total_return_pct is not None
    ]
    test_sharpes = [
        fold.test_metrics.sharpe_ratio
        for fold in summary.folds
        if fold.test_metrics.sharpe_ratio is not None
    ]
    return {
        "mean_test_return_pct": None if not test_returns else round(statistics.mean(test_returns), 2),
        "median_test_return_pct": None if not test_returns else round(statistics.median(test_returns), 2),
        "mean_test_sharpe": None if not test_sharpes else round(statistics.mean(test_sharpes), 2),
        "positive_fold_rate_pct": summary.test_aggregate.positive_fold_rate_pct,
        "worst_fold_return_pct": summary.test_aggregate.worst_return_pct,
    }
