"""ORB development lab orchestration CLI."""
from __future__ import annotations

import argparse
import asyncio
import hashlib
import json
from datetime import datetime
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import yaml

from libs.backtest.domain import WalkForwardSummary
from libs.common.config import get_settings
from libs.intraday.domain import IntradayMetrics, ORBStrategyParams
from libs.oracle_client import OracleClient

from apps.intraday_bt.orb_research import (
    DEFAULT_ORB_RESEARCH_PERIODS,
    ORBResearchPeriods,
    build_orb_params,
    build_orb_research_context,
    build_walk_forward_summary,
    compute_orbqs,
    force_simple_returns,
    intraday_metrics_to_split_result,
    resolve_lab_splits,
    resolve_orb_config,
    simulate_orb_overrides,
    write_json,
)
from apps.intraday_bt.oracle import make_intraday_oracle_client
from apps.intraday_bt.overfit_check import (
    run_param_plateau_test,
    run_permutation_test,
    summarize_is_oos_from_results,
    summarize_walk_forward_test_from_summary,
)
from apps.intraday_bt.orb_research import generate_walk_forward_windows


DEFAULT_CONFIG = "configs/intraday/strategies/orb_default.yaml"


@dataclass(frozen=True)
class EngineSpec:
    family: str
    thesis: str
    live_readiness: str
    base_overrides: dict[str, Any]
    hypotheses: list[dict[str, Any]]


def _read_json(path: Path, default: Any = None) -> Any:
    if not path.exists():
        return default
    return json.loads(path.read_text())


def _serialize_finalist_eval_row(row: dict[str, Any]) -> dict[str, Any]:
    def _dump(value: Any) -> Any:
        if hasattr(value, "model_dump"):
            return value.model_dump()
        return value

    return {
        **row,
        "params": _dump(row["params"]),
        "train_metrics_obj": _dump(row["train_metrics_obj"]),
        "valid_metrics_obj": _dump(row["valid_metrics_obj"]),
        "test_metrics_obj": _dump(row["test_metrics_obj"]),
        "train_result": _dump(row.get("train_result")),
        "valid_result": _dump(row.get("valid_result")),
        "test_result": _dump(row.get("test_result")),
    }


def _deserialize_finalist_eval_row(row: dict[str, Any]) -> dict[str, Any]:
    restored = dict(row)
    restored["params"] = ORBStrategyParams.model_validate(row["params"])
    restored["train_metrics_obj"] = IntradayMetrics.model_validate(row["train_metrics_obj"])
    restored["valid_metrics_obj"] = IntradayMetrics.model_validate(row["valid_metrics_obj"])
    restored["test_metrics_obj"] = IntradayMetrics.model_validate(row["test_metrics_obj"])
    restored["train_result"] = intraday_metrics_to_split_result(
        restored["train_metrics_obj"],
        restored["params"],
    )
    restored["valid_result"] = intraday_metrics_to_split_result(
        restored["valid_metrics_obj"],
        restored["params"],
    )
    restored["test_result"] = intraday_metrics_to_split_result(
        restored["test_metrics_obj"],
        restored["params"],
    )
    return restored


def _sample_representative_days(days: list[str], target_count: int) -> list[str]:
    """Pick an ordered, regime-spread subset of trading days for coarse search."""
    if target_count <= 0 or len(days) <= target_count:
        return list(days)
    last_idx = len(days) - 1
    chosen: list[str] = []
    seen: set[str] = set()
    for i in range(target_count):
        idx = round(i * last_idx / max(target_count - 1, 1))
        day = days[idx]
        if day not in seen:
            chosen.append(day)
            seen.add(day)
    return chosen


def _pre_robustness_rank_key(entry: dict[str, Any]) -> tuple[float, float, float, int]:
    return (
        entry.get("test_sharpe") or float("-inf"),
        entry.get("valid_sharpe") or float("-inf"),
        entry.get("train_sharpe") or float("-inf"),
        entry.get("test_trade_count") or 0,
    )


def _merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
    merged = dict(base)
    merged.update(extra)
    return merged


def _candidate_id(overrides: dict[str, Any]) -> str:
    payload = json.dumps(overrides, sort_keys=True, default=str)
    return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12]


def _rank_key(metrics) -> tuple[float, float, float, int]:
    return (
        metrics.sharpe_ratio or float("-inf"),
        metrics.total_return_pct or float("-inf"),
        -(abs(metrics.max_drawdown_pct) if metrics.max_drawdown_pct is not None else 999.0),
        metrics.total_trades or 0,
    )


def _rank_key_from_payload(payload: dict[str, Any]) -> tuple[float, float, float, int]:
    return (
        payload.get("sharpe_ratio") or float("-inf"),
        payload.get("total_return_pct") or float("-inf"),
        -(abs(payload.get("max_drawdown_pct")) if payload.get("max_drawdown_pct") is not None else 999.0),
        payload.get("total_trades") or 0,
    )


def _orbqs_rank_key(entry: dict[str, Any]) -> tuple[float, float, float, int]:
    return (
        entry.get("orbqs_score") or float("-inf"),
        entry.get("test_sharpe") or float("-inf"),
        entry.get("valid_sharpe") or float("-inf"),
        entry.get("test_trade_count") or 0,
    )


def _engine_specs(quick: bool) -> list[EngineSpec]:
    classic_hypotheses = [
        {
            "entry_direction": "long_only",
            "orb_minutes": 5,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 20,
            "atr_stop_multiplier": 1.25,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.0,
            "max_gap_pct": 0.04,
            "max_candidates": 20,
            "ticker_cooldown_days": 0,
        },
        {
            "entry_direction": "long_only",
            "orb_minutes": 10,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 20,
            "atr_stop_multiplier": 1.0,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.2,
            "max_gap_pct": 0.03,
            "max_candidates": 10,
            "ticker_cooldown_days": 1,
        },
        {
            "entry_direction": "both",
            "orb_minutes": 5,
            "sim_bar_minutes": 15,
            "order_timeout_minutes": 30,
            "atr_stop_multiplier": 1.5,
            "breakeven_at_r": 0.0,
            "trailing_at_r": 2.0,
            "trailing_stop_atr_multiplier": 0.0,
            "min_rvol": 0.8,
            "max_gap_pct": 0.06,
            "max_candidates": 20,
            "ticker_cooldown_days": 0,
        },
        {
            "entry_direction": "long_only",
            "orb_minutes": 10,
            "sim_bar_minutes": 15,
            "order_timeout_minutes": 30,
            "atr_stop_multiplier": 1.25,
            "breakeven_at_r": 0.0,
            "trailing_at_r": 2.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 0.8,
            "max_gap_pct": 0.04,
            "max_candidates": 10,
            "ticker_cooldown_days": 1,
        },
    ]
    if not quick:
        classic_hypotheses.extend(
            [
                {
                    "entry_direction": "both",
                    "orb_minutes": 15,
                    "sim_bar_minutes": 15,
                    "order_timeout_minutes": 30,
                    "atr_stop_multiplier": 1.25,
                    "breakeven_at_r": 1.0,
                    "trailing_at_r": 3.0,
                    "trailing_stop_atr_multiplier": 0.3,
                    "min_rvol": 1.0,
                    "max_gap_pct": 0.04,
                    "max_candidates": 20,
                    "ticker_cooldown_days": 1,
                },
                {
                    "entry_direction": "long_only",
                    "orb_minutes": 5,
                    "sim_bar_minutes": 30,
                    "order_timeout_minutes": 45,
                    "atr_stop_multiplier": 1.5,
                    "breakeven_at_r": 1.0,
                    "trailing_at_r": 5.0,
                    "trailing_stop_atr_multiplier": 0.6,
                    "min_rvol": 1.2,
                    "max_gap_pct": 0.03,
                    "max_candidates": 10,
                    "ticker_cooldown_days": 2,
                },
            ]
        )

    quality_hypotheses = [
        {
            "entry_direction": "long_only",
            "orb_minutes": 5,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 20,
            "atr_stop_multiplier": 1.25,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.2,
            "max_gap_pct": 0.03,
            "max_candidates": 10,
            "ticker_cooldown_days": 1,
            "min_body_ratio": 0.4,
            "weight_momentum": 0.1,
            "min_candidate_breadth": 0.2,
            "market_regime_spy_threshold": -0.005,
        },
        {
            "entry_direction": "long_only",
            "orb_minutes": 5,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 20,
            "atr_stop_multiplier": 1.25,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 2.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.2,
            "max_gap_pct": 0.03,
            "max_candidates": 8,
            "ticker_cooldown_days": 2,
            "min_body_ratio": 0.4,
            "weight_momentum": 0.2,
            "min_candidate_breadth": 0.3,
            "market_regime_spy_threshold": -0.005,
        },
        {
            "entry_direction": "long_only",
            "orb_minutes": 10,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 20,
            "atr_stop_multiplier": 1.0,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.2,
            "max_gap_pct": 0.03,
            "max_candidates": 10,
            "ticker_cooldown_days": 1,
            "min_body_ratio": 0.4,
            "weight_momentum": 0.1,
            "min_candidate_breadth": 0.2,
            "market_regime_spy_threshold": -0.005,
        },
        {
            "entry_direction": "long_only",
            "orb_minutes": 5,
            "sim_bar_minutes": 15,
            "order_timeout_minutes": 30,
            "atr_stop_multiplier": 1.25,
            "breakeven_at_r": 0.0,
            "trailing_at_r": 2.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.0,
            "max_gap_pct": 0.03,
            "max_candidates": 10,
            "ticker_cooldown_days": 1,
            "min_body_ratio": 0.2,
            "weight_momentum": 0.1,
            "min_candidate_breadth": 0.2,
            "market_regime_spy_threshold": -0.005,
        },
    ]
    if not quick:
        quality_hypotheses.extend(
            [
                {
                    "entry_direction": "long_only",
                    "orb_minutes": 15,
                    "sim_bar_minutes": 15,
                    "order_timeout_minutes": 30,
                    "atr_stop_multiplier": 1.0,
                    "breakeven_at_r": 1.0,
                    "trailing_at_r": 3.0,
                    "trailing_stop_atr_multiplier": 0.3,
                    "min_rvol": 1.2,
                    "max_gap_pct": 0.03,
                    "max_candidates": 10,
                    "ticker_cooldown_days": 1,
                    "min_body_ratio": 0.4,
                    "weight_momentum": 0.2,
                    "min_candidate_breadth": 0.3,
                    "market_regime_spy_threshold": -0.005,
                },
                {
                    "entry_direction": "both",
                    "orb_minutes": 5,
                    "sim_bar_minutes": 5,
                    "order_timeout_minutes": 20,
                    "atr_stop_multiplier": 1.5,
                    "breakeven_at_r": 0.0,
                    "trailing_at_r": 2.0,
                    "trailing_stop_atr_multiplier": 0.3,
                    "min_rvol": 0.8,
                    "max_gap_pct": 0.06,
                    "max_candidates": 20,
                    "ticker_cooldown_days": 2,
                    "min_body_ratio": 0.0,
                    "weight_momentum": 0.2,
                    "min_candidate_breadth": 0.2,
                    "market_regime_spy_threshold": None,
                },
            ]
        )

    compression_hypotheses = [
        {
            "orb_minutes": 10,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 45,
            "atr_stop_multiplier": 1.0,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.2,
            "max_gap_pct": 0.03,
            "max_candidates": 12,
            "max_candidates_per_sector": 2,
            "ticker_cooldown_days": 1,
            "weight_entropy": -0.15,
            "weight_atr_ratio": 0.0,
            "weight_gap_zscore": 0.2,
            "weight_premarket_dollar_vol": 0.25,
            "weight_gap": 0.25,
            "compression_ratio_max": 0.60,
            "min_candidate_breadth": 0.2,
            "market_regime_spy_threshold": -0.005,
            "min_candidates_to_trade": 3,
        },
        {
            "orb_minutes": 10,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 45,
            "atr_stop_multiplier": 1.0,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.2,
            "max_gap_pct": 0.03,
            "max_candidates": 15,
            "max_candidates_per_sector": 2,
            "ticker_cooldown_days": 1,
            "weight_entropy": -0.15,
            "weight_atr_ratio": 0.0,
            "weight_gap_zscore": 0.2,
            "weight_premarket_dollar_vol": 0.25,
            "weight_gap": 0.25,
            "compression_ratio_max": 0.60,
            "min_candidate_breadth": 0.2,
            "market_regime_spy_threshold": -0.005,
            "min_candidates_to_trade": 3,
        },
        {
            "orb_minutes": 10,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 45,
            "atr_stop_multiplier": 1.0,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.2,
            "max_gap_pct": 0.03,
            "max_candidates": 12,
            "max_candidates_per_sector": 3,
            "ticker_cooldown_days": 1,
            "weight_entropy": -0.15,
            "weight_atr_ratio": 0.0,
            "weight_gap_zscore": 0.2,
            "weight_premarket_dollar_vol": 0.25,
            "weight_gap": 0.25,
            "compression_ratio_max": 0.60,
            "min_candidate_breadth": 0.15,
            "market_regime_spy_threshold": -0.005,
            "min_candidates_to_trade": 3,
            "min_rvol": 1.1,
        },
        {
            "orb_minutes": 10,
            "sim_bar_minutes": 5,
            "order_timeout_minutes": 45,
            "atr_stop_multiplier": 1.0,
            "breakeven_at_r": 1.0,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "min_rvol": 1.1,
            "max_gap_pct": 0.03,
            "max_candidates": 15,
            "max_candidates_per_sector": 2,
            "ticker_cooldown_days": 1,
            "weight_entropy": -0.15,
            "weight_atr_ratio": 0.0,
            "weight_gap_zscore": 0.2,
            "weight_premarket_dollar_vol": 0.25,
            "weight_gap": 0.25,
            "compression_ratio_max": 0.60,
            "min_candidate_breadth": 0.15,
            "market_regime_spy_threshold": -0.005,
            "min_candidates_to_trade": 3,
        },
    ]
    if not quick:
        compression_hypotheses.extend(
            [
                {
                    "orb_minutes": 15,
                    "sim_bar_minutes": 15,
                    "order_timeout_minutes": 30,
                    "atr_stop_multiplier": 1.0,
                    "breakeven_at_r": 1.0,
                    "trailing_at_r": 3.0,
                    "trailing_stop_atr_multiplier": 0.3,
                    "min_rvol": 1.0,
                    "max_gap_pct": 0.03,
                    "max_candidates": 10,
                    "ticker_cooldown_days": 1,
                    "weight_entropy": -0.15,
                    "weight_atr_ratio": 0.0,
                    "weight_gap_zscore": 0.2,
                    "compression_ratio_max": 0.60,
                },
                {
                    "orb_minutes": 5,
                    "sim_bar_minutes": 30,
                    "order_timeout_minutes": 45,
                    "atr_stop_multiplier": 1.5,
                    "breakeven_at_r": 1.0,
                    "trailing_at_r": 5.0,
                    "trailing_stop_atr_multiplier": 0.6,
                    "min_rvol": 1.2,
                    "max_gap_pct": 0.03,
                    "max_candidates": 10,
                    "ticker_cooldown_days": 2,
                    "weight_entropy": 0.0,
                    "weight_atr_ratio": -0.15,
                    "weight_gap_zscore": 0.2,
                    "compression_ratio_max": 0.60,
                },
            ]
        )

    # Gainers hypotheses reflect the improved parameter space after strategy analysis.
    # Key improvements: early trailing activation, wider trail, R2G/doji allowance,
    # regime filter, simultaneous-entry cap.
    _gainers_base = {
        "entry_direction": "long_only",
        "orb_minutes": 5,
        "sim_bar_minutes": 5,
        "order_timeout_minutes": 45,
        "breakeven_at_r": 1.0,
        "min_rvol": 1.0,
        "max_gap_pct": None,
        "min_abs_gap_pct": 0.02,
        "min_premarket_dollar_vol": 1500000.0,
        "max_candidates": 20,
        "min_candidates_to_trade": 1,
        "ticker_cooldown_days": 0,
        "weight_rvol": 0.40,
        "weight_gap": 0.20,
        "weight_dollar_vol": 0.05,
        "weight_premarket_dollar_vol": 0.35,
        # New: allow leader followthrough patterns
        "allow_doji_breakout": True,
        "allow_red_to_green_breakout": True,
        # New: simultaneous entry cap to prevent correlated burst risk
        "max_simultaneous_entries": 5,
    }
    gainers_hypotheses = [
        # H1: Baseline improved — early trail (1.5R), loose trail (0.8 ATR), two-stage tighten
        {
            **_gainers_base,
            "atr_stop_multiplier": 0.75,
            "trailing_at_r": 1.5,
            "trailing_stop_atr_multiplier": 0.8,
            "trailing_tighten_at_r": 3.0,
            "trailing_stop_atr_multiplier_tight": 0.4,
            "max_candidates_per_sector": 3,
            "min_candidate_breadth": 0.30,
            "market_regime_spy_threshold": -0.005,
        },
        # H2: More aggressive trail (activate at 1R), wider stop
        {
            **_gainers_base,
            "atr_stop_multiplier": 0.75,
            "trailing_at_r": 1.0,
            "trailing_stop_atr_multiplier": 1.0,
            "trailing_tighten_at_r": 2.5,
            "trailing_stop_atr_multiplier_tight": 0.5,
            "max_candidates_per_sector": 3,
            "min_premarket_dollar_vol": 2000000.0,
            "min_candidate_breadth": 0.30,
            "market_regime_spy_threshold": -0.005,
        },
        # H3: Tighter regime filter, higher premarket bar, no sector cap
        {
            **_gainers_base,
            "atr_stop_multiplier": 1.0,
            "trailing_at_r": 1.5,
            "trailing_stop_atr_multiplier": 0.8,
            "trailing_tighten_at_r": 3.0,
            "trailing_stop_atr_multiplier_tight": 0.4,
            "max_candidates_per_sector": None,
            "min_premarket_dollar_vol": 3000000.0,
            "min_rvol": 1.5,
            "min_candidate_breadth": 0.40,
            "market_regime_spy_threshold": -0.008,
        },
        # H4: Original baseline (conservative) for comparison/regression
        {
            **_gainers_base,
            "atr_stop_multiplier": 0.50,
            "trailing_at_r": 3.0,
            "trailing_stop_atr_multiplier": 0.3,
            "trailing_tighten_at_r": None,
            "trailing_stop_atr_multiplier_tight": 0.0,
            "max_candidates_per_sector": 2,
            "min_rvol": 1.2,
            "max_candidates": 12,
            "max_simultaneous_entries": None,
            "allow_doji_breakout": False,
            "allow_red_to_green_breakout": False,
            "min_candidate_breadth": None,
            "market_regime_spy_threshold": None,
        },
    ]

    return [
        EngineSpec(
            family="classic_breakout",
            thesis="Pure breakout timing and risk discipline over broad ORB participation.",
            live_readiness="live_ready",
            base_overrides={
                "engine_family": "classic_breakout",
                "live_readiness": "live_ready",
                "weight_body_ratio": 0.0,
                "weight_momentum": 0.0,
                "weight_entropy": 0.0,
                "weight_atr_ratio": 0.0,
                "weight_gap_zscore": 0.0,
                "min_body_ratio": 0.0,
            },
            hypotheses=classic_hypotheses,
        ),
        EngineSpec(
            family="quality_breakout",
            thesis="Higher-quality ORB candles with body strength, momentum, and breadth filters.",
            live_readiness="live_ready",
            base_overrides={
                "engine_family": "quality_breakout",
                "live_readiness": "live_ready",
                "weight_body_ratio": 0.15,
            },
            hypotheses=quality_hypotheses,
        ),
        EngineSpec(
            family="gainers_leader",
            thesis="Top-gainers style ORB that emphasizes abnormal 09:35 attention, gap, and premarket participation.",
            live_readiness="research_only",
            base_overrides={
                "engine_family": "gainers_leader",
                "live_readiness": "research_only",
                "entry_direction": "long_only",
                "weight_body_ratio": 0.0,
                "weight_momentum": 0.0,
            },
            hypotheses=gainers_hypotheses,
        ),
        EngineSpec(
            family="compression_breakout",
            thesis="Compressed prior-day regime followed by expansion through the opening range.",
            live_readiness="research_only",
            base_overrides={
                "engine_family": "compression_breakout",
                "live_readiness": "research_only",
                "entry_direction": "long_only",
                "weight_body_ratio": 0.10,
                "weight_momentum": 0.10,
                "max_entropy": 0.95,
            },
            hypotheses=compression_hypotheses,
        ),
    ]


async def _evaluate_hypotheses_family(
    spec: EngineSpec,
    context,
    client: OracleClient,
    train_days: list[str],
    *,
    keep_top: int,
) -> list[dict[str, Any]]:
    print(f"\n[coarse] {spec.family}")
    print(f"  thesis: {spec.thesis}")
    evaluated: list[dict[str, Any]] = []
    for idx, hypothesis in enumerate(spec.hypotheses, start=1):
        overrides = _merge(spec.base_overrides, hypothesis)
        cid = _candidate_id(overrides)
        params, metrics = await simulate_orb_overrides(
            context,
            client,
            overrides,
            train_days,
            run_id=f"{spec.family[:4]}_{idx:03d}",
        )
        row = {
            "candidate_id": cid,
            "engine_family": spec.family,
            "live_readiness": spec.live_readiness,
            "thesis": spec.thesis,
            "hypothesis_index": idx,
            "overrides": overrides,
            "params": params.model_dump(),
            "train_metrics": metrics.model_dump(),
        }
        evaluated.append(row)
        print(
            f"  hypothesis {idx}/{len(spec.hypotheses)} "
            f"Sharpe={metrics.sharpe_ratio or 0:.2f} "
            f"Ret={(metrics.total_return_pct or 0)*100:.1f}%"
        )

    survivors = sorted(
        evaluated,
        key=lambda row: _rank_key_from_payload(row["train_metrics"]),
        reverse=True,
    )[:keep_top]
    return survivors


async def _rerank_family(
    spec: EngineSpec,
    survivors: list[dict[str, Any]],
    context,
    client: OracleClient,
    evaluation_days: list[str],
    *,
    keep_top: int,
    metric_field: str,
    existing_rows: list[dict[str, Any]] | None = None,
    on_update: Any | None = None,
) -> list[dict[str, Any]]:
    print(f"\n[rerank] {spec.family}")
    reranked: list[dict[str, Any]] = list(existing_rows or [])
    completed_ids = {row["candidate_id"] for row in reranked}
    for idx, survivor in enumerate(survivors, start=1):
        if survivor["candidate_id"] in completed_ids:
            print(f"  {idx}/{len(survivors)} resume hit")
            continue
        _, metrics = await simulate_orb_overrides(
            context,
            client,
            survivor["overrides"],
            evaluation_days,
            run_id=f"{spec.family[:4]}_rv_{idx:03d}",
            progress_prefix=f"  [rerank {spec.family} {idx}/{len(survivors)}] ",
            intraday_concurrency=2,
            max_pairs_per_chunk=2_000,
        )
        row = dict(survivor)
        row[metric_field] = metrics.model_dump()
        reranked.append(row)
        completed_ids.add(survivor["candidate_id"])
        reranked.sort(key=lambda item: _rank_key_from_payload(item[metric_field]), reverse=True)
        if on_update is not None:
            on_update(reranked)
        print(
            f"  {idx}/{len(survivors)} "
            f"Sharpe={metrics.sharpe_ratio or 0:.2f} "
            f"Ret={(metrics.total_return_pct or 0)*100:.1f}%"
        )
    return reranked[:keep_top]


async def _build_walk_forward_for_candidate(
    context,
    client: OracleClient,
    overrides: dict[str, Any],
    *,
    train_days: int,
    test_days: int,
    step_days: int,
    progress_prefix: str = "",
) -> Any:
    wf_train_days = train_days
    wf_test_days = test_days
    windows = generate_walk_forward_windows(
        context.trading_days,
        train_days=wf_train_days,
        test_days=wf_test_days,
        step_days=step_days,
    )
    folds: list[dict[str, Any]] = []
    params = build_orb_params(context.config, overrides)
    for idx, (fold_train_days, fold_test_days) in enumerate(windows, start=1):
        if progress_prefix:
            print(
                f"{progress_prefix}fold {idx}/{len(windows)}: "
                f"train {fold_train_days[0]}→{fold_train_days[-1]} "
                f"test {fold_test_days[0]}→{fold_test_days[-1]}"
            )
        train_metrics = await simulate_orb_overrides(
            context,
            client,
            overrides,
            fold_train_days,
            run_id=f"wf_tr_{idx:02d}",
            progress_prefix=f"{progress_prefix}[train {idx}/{len(windows)}] " if progress_prefix else "",
        )
        test_metrics = await simulate_orb_overrides(
            context,
            client,
            overrides,
            fold_test_days,
            run_id=f"wf_te_{idx:02d}",
            progress_prefix=f"{progress_prefix}[test {idx}/{len(windows)}] " if progress_prefix else "",
        )
        folds.append({
            "train_start": fold_train_days[0],
            "train_end": fold_train_days[-1],
            "test_start": fold_test_days[0],
            "test_end": fold_test_days[-1],
            "train_result": intraday_metrics_to_split_result(train_metrics[1], params),
            "test_result": intraday_metrics_to_split_result(test_metrics[1], params),
        })
    return build_walk_forward_summary(
        folds,
        train_days=wf_train_days,
        test_days=wf_test_days,
        step_days=step_days,
    )


async def _run_finalist_scenarios(
    robustness_context,
    main_context,
    client: OracleClient,
    periods: ORBResearchPeriods,
    test_metrics,
    *,
    quick: bool,
) -> dict[str, dict[str, Any]]:
    if quick:
        robustness_days = robustness_context.trading_days
        head_end = robustness_days[min(len(robustness_days) - 1, 62)]
        tail_start = robustness_days[max(0, len(robustness_days) - 63)]
        scenario_defs = {
            "robustness_head": {"start": robustness_days[0], "end": head_end},
            "robustness_tail": {"start": tail_start, "end": robustness_days[-1]},
            "no_rvol_filter": {
                "start": tail_start,
                "end": robustness_days[-1],
                "param_override": {"min_rvol": 0.0},
            },
        }
    elif periods == DEFAULT_ORB_RESEARCH_PERIODS:
        scenario_defs = {
            "bear_2022": {"start": "2022-01-03", "end": "2022-12-30"},
            "recovery_2023h1": {"start": "2023-01-03", "end": "2023-06-30"},
            "bull_2023h2": {"start": "2023-07-03", "end": "2023-12-29"},
            "no_rvol_filter": {"param_override": {"min_rvol": 0.0}},
            "random_ranking": {"shuffle_candidates": True},
        }
    else:
        robustness_days = robustness_context.trading_days
        midpoint = len(robustness_days) // 2
        scenario_defs = {
            "robustness_1": {"start": robustness_days[0], "end": robustness_days[max(0, midpoint - 1)]},
            "robustness_2": {"start": robustness_days[midpoint], "end": robustness_days[-1]},
            "no_rvol_filter": {"param_override": {"min_rvol": 0.0}},
            "random_ranking": {"shuffle_candidates": True},
        }
    results: dict[str, dict[str, Any]] = {}
    from apps.intraday_bt.scenario_test import run_scenario

    for name, definition in scenario_defs.items():
        results[name] = await run_scenario(
            name,
            definition,
            robustness_context,
            client,
            robustness_context.trading_days[0],
            progress_prefix="    [scenario] ",
        )
    results["oos_2026"] = {
        "scenario": "oos_2026",
        "period": f"{main_context.trading_days[-1]}",
        "sharpe_ratio": test_metrics.sharpe_ratio or 0.0,
        "total_return_pct": (test_metrics.total_return_pct or 0.0) * 100.0,
        "max_drawdown_pct": abs((test_metrics.max_drawdown_pct or 0.0) * 100.0),
        "win_rate": (test_metrics.win_rate or 0.0) * 100.0,
        "profit_factor": test_metrics.profit_factor or 0.0,
        "total_trades": test_metrics.total_trades or 0,
    }
    return results


def _write_stage5_payload(
    path: Path,
    *,
    ranking: list[dict[str, Any]],
    walk_forward: dict[str, Any],
    scenarios: dict[str, Any],
    overfit: dict[str, Any],
) -> None:
    write_json(
        path,
        {
            "ranking": ranking,
            "walk_forward": walk_forward,
            "scenarios": scenarios,
            "overfit": overfit,
        },
    )


def _promotion_status(valid_result, test_result) -> str:
    if valid_result.trade_count < 80 or test_result.trade_count < 80:
        return "blocked_low_activity"
    if (valid_result.total_return_pct or 0.0) <= 0.0 or (test_result.total_return_pct or 0.0) <= 0.0:
        return "blocked_negative_oos"
    return "eligible"


def _select_champions(
    ranking: list[dict[str, Any]],
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, dict[str, Any] | None]:
    top_candidate = ranking[0] if ranking else None
    eligible_rows = [row for row in ranking if row.get("promotion_status") == "eligible"]
    overall = eligible_rows[0] if eligible_rows else None
    live_ready = next((row for row in eligible_rows if row.get("live_readiness") == "live_ready"), None)
    return top_candidate, overall, live_ready


def _finalist_summary_row(row: dict[str, Any]) -> dict[str, Any]:
    return {
        "candidate_id": row["candidate_id"],
        "engine_family": row["engine_family"],
        "live_readiness": row["live_readiness"],
        "promotion_status": row["promotion_status"],
        "overrides": row["overrides"],
        "train_sharpe": row["train_metrics_obj"].sharpe_ratio,
        "valid_sharpe": row["valid_metrics_obj"].sharpe_ratio,
        "test_sharpe": row["test_metrics_obj"].sharpe_ratio,
        "train_trade_count": row["train_metrics_obj"].total_trades,
        "valid_trade_count": row["valid_metrics_obj"].total_trades,
        "test_trade_count": row["test_metrics_obj"].total_trades,
        "train_return_pct": (row["train_metrics_obj"].total_return_pct or 0.0) * 100.0,
        "valid_return_pct": (row["valid_metrics_obj"].total_return_pct or 0.0) * 100.0,
        "test_return_pct": (row["test_metrics_obj"].total_return_pct or 0.0) * 100.0,
    }


def _resolve_period_overrides(args: argparse.Namespace) -> ORBResearchPeriods:
    defaults = DEFAULT_ORB_RESEARCH_PERIODS
    return ORBResearchPeriods(
        train_start=args.train_start or defaults.train_start,
        train_end=args.train_end or defaults.train_end,
        valid_start=args.valid_start or defaults.valid_start,
        valid_end=args.valid_end or defaults.valid_end,
        test_start=args.test_start or defaults.test_start,
        test_end=args.test_end or defaults.test_end,
        robustness_start=args.robustness_start or defaults.robustness_start,
        robustness_end=args.robustness_end or defaults.robustness_end,
    )


async def run_lab(
    config_path: str,
    *,
    periods: ORBResearchPeriods = DEFAULT_ORB_RESEARCH_PERIODS,
    quick: bool,
    beam_width: int,
    wf_train_days: int | None = None,
    wf_test_days: int | None = None,
    permutations: int | None = None,
    output_dir: str | None = None,
) -> dict[str, Any]:
    _, base_config = resolve_orb_config(config_path)
    base_config = force_simple_returns(base_config)
    wf_train_days = wf_train_days or (84 if quick else 252)
    wf_test_days = wf_test_days or (21 if quick else 63)
    wf_step_days = 42 if quick else wf_test_days
    permutations = permutations or (3 if quick else 20)
    coarse_keep = max(1, beam_width) if quick else max(beam_width, 5)
    rerank_keep = 1 if quick else 2
    coarse_sample_days = 42 if quick else 126
    robustness_keep = 1 if quick else 3
    output_root = Path(output_dir) if output_dir else Path("runs/intraday_orb/lab") / f"{Path(config_path).stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
    output_root.mkdir(parents=True, exist_ok=True)
    stage1_path = output_root / "stage1_coarse.json"
    stage2_path = output_root / "stage2_rerank.json"
    stage4_path = output_root / "stage4_locked_test.json"
    stage5_path = output_root / "stage5_robustness.json"

    settings = get_settings()
    async with make_intraday_oracle_client(settings) as client:
        print("[1/6] Building main research context (2024-2026Q1)...")
        main_context = await build_orb_research_context(
            base_config,
            periods.train_start,
            periods.test_end,
            client,
            print_progress=True,
        )
        splits = resolve_lab_splits(main_context.trading_days, periods)
        train_days = splits["train"]
        valid_days = splits["valid"]
        test_days = splits["test"]
        combined_days = train_days + valid_days
        coarse_train_days = _sample_representative_days(train_days, coarse_sample_days)

        print(
            f"[2/6] Coarse search on representative train slice "
            f"({len(coarse_train_days)}/{len(train_days)} days)..."
        )
        stage1: dict[str, list[dict[str, Any]]] = _read_json(stage1_path, default={})
        for spec in _engine_specs(quick):
            if spec.family in stage1:
                print(f"\n[coarse] {spec.family} (resume hit)")
                continue
            stage1[spec.family] = await _evaluate_hypotheses_family(
                spec,
                main_context,
                client,
                coarse_train_days,
                keep_top=coarse_keep,
            )
            write_json(stage1_path, stage1)

        rerank_days = valid_days if quick else combined_days
        rerank_metric_field = "valid_metrics" if quick else "train_valid_metrics"
        rerank_label = "valid only" if quick else "train+valid"
        print(f"[3/6] Re-rank survivors on {rerank_label}...")
        stage2: dict[str, list[dict[str, Any]]] = _read_json(stage2_path, default={})
        for spec in _engine_specs(quick):
            existing_stage2 = [
                row for row in stage2.get(spec.family, [])
                if rerank_metric_field in row
            ]
            if existing_stage2:
                print(f"\n[rerank] {spec.family} ({len(existing_stage2)} cached)")
            def _save_stage2(rows: list[dict[str, Any]], family: str = spec.family) -> None:
                stage2[family] = rows
                write_json(stage2_path, stage2)
            stage2[spec.family] = await _rerank_family(
                spec,
                stage1[spec.family],
                main_context,
                client,
                rerank_days,
                keep_top=rerank_keep,
                metric_field=rerank_metric_field,
                existing_rows=existing_stage2,
                on_update=_save_stage2,
            )
            write_json(stage2_path, stage2)

        finalists = [row for rows in stage2.values() for row in rows]
        print(f"[4/6] Locked test on finalists ({len(finalists)} configs)...")
        stage4_payload = _read_json(stage4_path, default={"split_rows": [], "finalists": []})
        split_rows: list[dict[str, Any]] = list(stage4_payload.get("split_rows", []))
        finalist_eval_rows: list[dict[str, Any]] = [
            _deserialize_finalist_eval_row(row)
            for row in stage4_payload.get("finalists", [])
        ]
        normalized_finalist_eval_rows: list[dict[str, Any]] = []
        for row in finalist_eval_rows:
            promotion_status = _promotion_status(row["valid_result"], row["test_result"])
            normalized_finalist_eval_rows.append(
                {
                    **row,
                    "promotion_status": promotion_status,
                }
            )
        finalist_eval_rows = normalized_finalist_eval_rows
        finalist_eval_by_candidate = {
            row["candidate_id"]: row
            for row in finalist_eval_rows
        }
        split_rows = [
            {
                **row,
                "promotion_status": finalist_eval_by_candidate[row["candidate_id"]]["promotion_status"],
            }
            if row["candidate_id"] in finalist_eval_by_candidate
            else row
            for row in split_rows
        ]
        stage4_payload = {
            "split_rows": split_rows,
            "finalists": [_serialize_finalist_eval_row(row) for row in finalist_eval_rows],
        }
        write_json(stage4_path, stage4_payload)
        completed_finalists = {row["candidate_id"] for row in finalist_eval_rows}
        for idx, finalist in enumerate(finalists, start=1):
            if finalist["candidate_id"] in completed_finalists:
                print(f"  {idx}/{len(finalists)} {finalist['engine_family']} (resume hit)")
                continue
            overrides = finalist["overrides"]
            params = build_orb_params(main_context.config, overrides)
            train_metrics_obj = IntradayMetrics.model_validate(finalist["train_metrics"])
            if "valid_metrics" in finalist:
                valid_metrics_obj = IntradayMetrics.model_validate(finalist["valid_metrics"])
                print(f"  [valid {idx}/{len(finalists)}] cached metrics hit from stage2")
            else:
                _, valid_metrics_obj = await simulate_orb_overrides(
                    main_context,
                    client,
                    overrides,
                    valid_days,
                    run_id=f"val_{idx:03d}",
                    progress_prefix=f"  [valid {idx}/{len(finalists)}] ",
                    intraday_concurrency=2,
                    max_pairs_per_chunk=2_000,
                )
            _, test_metrics_obj = await simulate_orb_overrides(
                main_context,
                client,
                overrides,
                test_days,
                run_id=f"test_{idx:03d}",
                progress_prefix=f"  [test {idx}/{len(finalists)}] ",
                intraday_concurrency=2,
                max_pairs_per_chunk=2_000,
            )
            train_result = intraday_metrics_to_split_result(train_metrics_obj, params)
            valid_result = intraday_metrics_to_split_result(valid_metrics_obj, params)
            test_result = intraday_metrics_to_split_result(test_metrics_obj, params)
            promotion_status = _promotion_status(valid_result, test_result)
            split_row = {
                "candidate_id": finalist["candidate_id"],
                "engine_family": finalist["engine_family"],
                "live_readiness": finalist["live_readiness"],
                "promotion_status": promotion_status,
                "overrides": overrides,
                "train": train_result.model_dump(),
                "valid": valid_result.model_dump(),
                "test": test_result.model_dump(),
            }
            split_rows.append(split_row)
            finalist_row = {
                **finalist,
                "params": params,
                "train_metrics_obj": train_metrics_obj,
                "valid_metrics_obj": valid_metrics_obj,
                "test_metrics_obj": test_metrics_obj,
                "train_result": train_result,
                "valid_result": valid_result,
                "test_result": test_result,
                "promotion_status": promotion_status,
            }
            finalist_eval_rows.append(finalist_row)
            stage4_payload = {
                "split_rows": split_rows,
                "finalists": [_serialize_finalist_eval_row(row) for row in finalist_eval_rows],
            }
            write_json(stage4_path, stage4_payload)
            print(
                f"  {idx}/{len(finalists)} {finalist['engine_family']} "
                f"test Sharpe={test_metrics_obj.sharpe_ratio or 0:.2f} "
                f"Ret={(test_metrics_obj.total_return_pct or 0)*100:.1f}% "
                f"Trades={test_metrics_obj.total_trades or 0}"
            )

        finalist_eval_rows.sort(
            key=lambda row: _pre_robustness_rank_key(
                {
                    "train_sharpe": row["train_metrics_obj"].sharpe_ratio,
                    "valid_sharpe": row["valid_metrics_obj"].sharpe_ratio,
                    "test_sharpe": row["test_metrics_obj"].sharpe_ratio,
                    "test_trade_count": row["test_metrics_obj"].total_trades,
                }
            ),
            reverse=True,
        )
        top_split_candidate = finalist_eval_rows[0] if finalist_eval_rows else None
        eligible_finalists = [
            row for row in finalist_eval_rows
            if row["promotion_status"] == "eligible"
        ]
        robustness_finalists = eligible_finalists[:robustness_keep]

        ranking: list[dict[str, Any]] = []
        wf_by_candidate: dict[str, Any] = {}
        scenarios_by_candidate: dict[str, Any] = {}
        overfit_by_candidate: dict[str, Any] = {}

        if robustness_finalists:
            print(
                f"[5/6] Finalist robustness (WFV + scenario + overfit) "
                f"on top {len(robustness_finalists)}/{len(finalist_eval_rows)} finalists..."
            )
            robustness_context = await build_orb_research_context(
                base_config,
                periods.robustness_start,
                periods.robustness_end,
                client,
                print_progress=True,
            )

            stage5_payload = _read_json(
                stage5_path,
                default={
                    "ranking": [],
                    "walk_forward": {},
                    "scenarios": {},
                    "overfit": {},
                },
            )
            ranking = list(stage5_payload.get("ranking", []))
            wf_by_candidate = dict(stage5_payload.get("walk_forward", {}))
            scenarios_by_candidate = dict(stage5_payload.get("scenarios", {}))
            overfit_by_candidate = dict(stage5_payload.get("overfit", {}))
            finalist_by_candidate_id = {
                row["candidate_id"]: row
                for row in finalist_eval_rows
            }
            normalized_ranking: list[dict[str, Any]] = []
            for row in ranking:
                finalist = finalist_by_candidate_id.get(row["candidate_id"])
                if finalist is None:
                    normalized_ranking.append(row)
                    continue
                normalized_ranking.append(
                    {
                        **row,
                        "engine_family": finalist["engine_family"],
                        "live_readiness": finalist["live_readiness"],
                        "promotion_status": finalist["promotion_status"],
                        "overrides": finalist["overrides"],
                    }
                )
            ranking = normalized_ranking
            ranked_candidates = {row["candidate_id"] for row in ranking}

            for idx, finalist in enumerate(robustness_finalists, start=1):
                candidate_id = finalist["candidate_id"]
                if finalist["candidate_id"] in ranked_candidates:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {finalist['candidate_id']} (resume hit)")
                    continue
                params = finalist["params"]
                config_for_finalist = main_context.config.model_copy(update={"orb_strategy": params})
                wf_payload = wf_by_candidate.get(candidate_id)
                if wf_payload is None:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} walk-forward...")
                    wf_summary = await _build_walk_forward_for_candidate(
                        main_context,
                        client,
                        finalist["overrides"],
                        train_days=wf_train_days,
                        test_days=wf_test_days,
                        step_days=wf_step_days,
                        progress_prefix="    [wf] ",
                    )
                    wf_payload = wf_summary.model_dump(mode="json")
                    wf_by_candidate[candidate_id] = wf_payload
                    _write_stage5_payload(
                        stage5_path,
                        ranking=ranking,
                        walk_forward=wf_by_candidate,
                        scenarios=scenarios_by_candidate,
                        overfit=overfit_by_candidate,
                    )
                else:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} walk-forward (resume hit)")
                    wf_summary = WalkForwardSummary.model_validate(wf_payload)

                scenario_results = scenarios_by_candidate.get(candidate_id)
                if scenario_results is None:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} scenarios...")
                    scenario_results = await _run_finalist_scenarios(
                        robustness_context,
                        main_context,
                        client,
                        periods,
                        finalist["test_metrics_obj"],
                        quick=quick,
                    )
                    scenarios_by_candidate[candidate_id] = scenario_results
                    _write_stage5_payload(
                        stage5_path,
                        ranking=ranking,
                        walk_forward=wf_by_candidate,
                        scenarios=scenarios_by_candidate,
                        overfit=overfit_by_candidate,
                    )
                else:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} scenarios (resume hit)")

                overfit_tests = dict(overfit_by_candidate.get(candidate_id, {}))
                if "walk_forward" not in overfit_tests:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit walk-forward (reuse)...")
                    overfit_tests["walk_forward"] = summarize_walk_forward_test_from_summary(wf_summary)
                    overfit_by_candidate[candidate_id] = overfit_tests
                    _write_stage5_payload(
                        stage5_path,
                        ranking=ranking,
                        walk_forward=wf_by_candidate,
                        scenarios=scenarios_by_candidate,
                        overfit=overfit_by_candidate,
                    )
                if "is_oos" not in overfit_tests:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit is_oos (reuse)...")
                    overfit_tests["is_oos"] = summarize_is_oos_from_results(
                        finalist["train_result"],
                        finalist["test_result"],
                        is_period=f"{periods.train_start} → {periods.valid_end}",
                        oos_period=f"{periods.test_start} → {periods.test_end}",
                    )
                    overfit_by_candidate[candidate_id] = overfit_tests
                    _write_stage5_payload(
                        stage5_path,
                        ranking=ranking,
                        walk_forward=wf_by_candidate,
                        scenarios=scenarios_by_candidate,
                        overfit=overfit_by_candidate,
                    )
                if "param_plateau" not in overfit_tests:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit plateau...")
                    overfit_tests["param_plateau"] = await run_param_plateau_test(
                        main_context,
                        client,
                        config_for_finalist,
                        quick=quick,
                        param_names=["atr_stop_multiplier"] if quick else None,
                    )
                    overfit_by_candidate[candidate_id] = overfit_tests
                    _write_stage5_payload(
                        stage5_path,
                        ranking=ranking,
                        walk_forward=wf_by_candidate,
                        scenarios=scenarios_by_candidate,
                        overfit=overfit_by_candidate,
                    )
                if "permutation" not in overfit_tests:
                    print(f"  finalist {idx}/{len(robustness_finalists)} {candidate_id} overfit permutation...")
                    overfit_tests["permutation"] = await run_permutation_test(
                        main_context,
                        client,
                        config_for_finalist,
                        n_permutations=permutations,
                    )
                    overfit_by_candidate[candidate_id] = overfit_tests
                    _write_stage5_payload(
                        stage5_path,
                        ranking=ranking,
                        walk_forward=wf_by_candidate,
                        scenarios=scenarios_by_candidate,
                        overfit=overfit_by_candidate,
                    )

                orbqs_score, orbqs_breakdown = compute_orbqs(
                    finalist["train_result"],
                    finalist["valid_result"],
                    finalist["test_result"],
                    wf_summary,
                    scenario_results,
                    overfit_tests,
                )
                rank_row = {
                    "candidate_id": finalist["candidate_id"],
                    "engine_family": finalist["engine_family"],
                    "live_readiness": finalist["live_readiness"],
                    "promotion_status": finalist["promotion_status"],
                    "overrides": finalist["overrides"],
                    "orbqs_score": orbqs_score,
                    "orbqs_breakdown": orbqs_breakdown,
                    "train_sharpe": finalist["train_metrics_obj"].sharpe_ratio,
                    "valid_sharpe": finalist["valid_metrics_obj"].sharpe_ratio,
                    "test_sharpe": finalist["test_metrics_obj"].sharpe_ratio,
                    "train_trade_count": finalist["train_metrics_obj"].total_trades,
                    "valid_trade_count": finalist["valid_metrics_obj"].total_trades,
                    "test_trade_count": finalist["test_metrics_obj"].total_trades,
                    "train_return_pct": (finalist["train_metrics_obj"].total_return_pct or 0.0) * 100.0,
                    "valid_return_pct": (finalist["valid_metrics_obj"].total_return_pct or 0.0) * 100.0,
                    "test_return_pct": (finalist["test_metrics_obj"].total_return_pct or 0.0) * 100.0,
                }
                ranking.append(rank_row)
                wf_by_candidate[candidate_id] = wf_summary.model_dump(mode="json")
                scenarios_by_candidate[candidate_id] = scenario_results
                overfit_by_candidate[candidate_id] = overfit_tests
                _write_stage5_payload(
                    stage5_path,
                    ranking=ranking,
                    walk_forward=wf_by_candidate,
                    scenarios=scenarios_by_candidate,
                    overfit=overfit_by_candidate,
                )
                print(
                    f"  finalist {idx}/{len(robustness_finalists)} "
                    f"{finalist['candidate_id']} ORBQS={orbqs_score if orbqs_score is not None else 'NA'}"
                )
        else:
            print("[5/6] No eligible finalists after locked test; skipping robustness.")
            _write_stage5_payload(
                stage5_path,
                ranking=[],
                walk_forward={},
                scenarios={},
                overfit={},
            )

    ranking.sort(key=_orbqs_rank_key, reverse=True)
    top_candidate_from_rank, overall, live_ready = _select_champions(ranking)
    top_candidate = top_candidate_from_rank or (
        _finalist_summary_row(top_split_candidate) if top_split_candidate is not None else None
    )

    write_json(output_root / "split_results.json", split_rows)
    write_json(output_root / "ranking.json", ranking)

    summary = {
        "output_dir": str(output_root),
        "config": str(config_path),
        "periods": {
            "train": [periods.train_start, periods.train_end],
            "valid": [periods.valid_start, periods.valid_end],
            "test": [periods.test_start, periods.test_end],
            "robustness": [periods.robustness_start, periods.robustness_end],
        },
        "quick": quick,
        "research_mode": "hypothesis_first",
        "beam_width": beam_width,
        "wf_train_days": wf_train_days,
        "wf_test_days": wf_test_days,
        "wf_step_days": wf_step_days,
        "permutations": permutations,
        "coarse_sample_days": len(coarse_train_days),
        "stage1_counts": {family: len(rows) for family, rows in stage1.items()},
        "stage2_counts": {family: len(rows) for family, rows in stage2.items()},
        "robustness_candidates": len(robustness_finalists),
        "top_candidate": top_candidate,
        "overall_champion": overall,
        "best_live_ready_champion": live_ready,
        "top_candidate_id": top_candidate["candidate_id"] if top_candidate else None,
        "overall_champion_candidate_id": overall["candidate_id"] if overall else None,
        "best_live_ready_candidate_id": live_ready["candidate_id"] if live_ready else None,
        "top_candidate_orbqs": top_candidate.get("orbqs_score") if top_candidate else None,
        "overall_champion_orbqs": overall.get("orbqs_score") if overall else None,
        "best_live_ready_orbqs": live_ready.get("orbqs_score") if live_ready else None,
        "ranking_count": len(ranking),
    }
    write_json(output_root / "summary.json", summary)

    if overall is not None:
        champion_params = build_orb_params(base_config, overall["overrides"])
        champion_config = base_config.model_dump()
        champion_config["orb_strategy"] = champion_params.model_dump()
        (output_root / "champion.yaml").write_text(
            yaml.safe_dump(champion_config, sort_keys=False, allow_unicode=False)
        )
        write_json(output_root / "walk_forward_summary.json", wf_by_candidate[overall["candidate_id"]])
        write_json(output_root / "scenario_report.json", scenarios_by_candidate[overall["candidate_id"]])
        write_json(output_root / "overfit_report.json", overfit_by_candidate[overall["candidate_id"]])
    else:
        (output_root / "champion.yaml").write_text("")
        write_json(output_root / "walk_forward_summary.json", {})
        write_json(output_root / "scenario_report.json", {})
        write_json(output_root / "overfit_report.json", {})

    print(f"[6/6] Complete → {output_root}")
    return {
        "output_dir": str(output_root),
        "summary": summary,
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="fithia2 intraday-orb-lab",
        description="ORB research lab orchestration (coarse → rerank → test → robustness → rank)",
    )
    parser.add_argument("--config", default=DEFAULT_CONFIG, help="Base ORB config YAML or slug")
    parser.add_argument("--quick", action="store_true", help="Use a reduced search grid for smoke tests")
    parser.add_argument("--beam-width", type=int, default=6, help="Per-family survivor cap for coarse stage")
    parser.add_argument("--train-start", default=None)
    parser.add_argument("--train-end", default=None)
    parser.add_argument("--valid-start", default=None)
    parser.add_argument("--valid-end", default=None)
    parser.add_argument("--test-start", default=None)
    parser.add_argument("--test-end", default=None)
    parser.add_argument("--robustness-start", default=None)
    parser.add_argument("--robustness-end", default=None)
    parser.add_argument("--wf-train-days", type=int, default=None)
    parser.add_argument("--wf-test-days", type=int, default=None)
    parser.add_argument("--permutations", type=int, default=None)
    parser.add_argument("--output-dir", default=None, help="Optional output directory")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    periods = _resolve_period_overrides(args)
    result = asyncio.run(
        run_lab(
            args.config,
            periods=periods,
            quick=args.quick,
            beam_width=args.beam_width,
            wf_train_days=args.wf_train_days,
            wf_test_days=args.wf_test_days,
            permutations=args.permutations,
            output_dir=args.output_dir,
        )
    )
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()
