"""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, }, ] _pullback_base = { "entry_direction": "long_only", "orb_minutes": 5, "sim_bar_minutes": 5, "order_timeout_minutes": 45, "breakeven_at_r": 1.0, "min_rvol": 1.2, "max_gap_pct": 0.05, "min_abs_gap_pct": 0.02, "min_premarket_dollar_vol": 1_500_000.0, "max_candidates": 18, "max_candidates_per_sector": 3, "min_candidates_to_trade": 1, "ticker_cooldown_days": 0, "weight_rvol": 0.35, "weight_gap": 0.20, "weight_dollar_vol": 0.05, "weight_premarket_dollar_vol": 0.25, "weight_body_ratio": 0.0, "weight_momentum": 0.10, "weight_close_location": 0.05, "weight_gap_zscore": 0.05, "weight_obv_slope": 0.07, "weight_entropy": -0.03, "weight_event_catalyst": 0.10, "allow_doji_breakout": True, "allow_red_to_green_breakout": True, "min_body_ratio": 0.0, "pullback_entry": True, "pullback_max_bars": 7, "pullback_min_retracement_pct": 0.25, "pullback_impulse_window_end_min": 30, "pullback_impulse_min_move_atr": 0.35, "pullback_depth_max_pct": 0.60, "pullback_volume_contraction_ratio": 0.90, "pullback_vwap_floor": True, "pullback_vwap_floor_tolerance_pct": 0.003, "pullback_require_breakout_retake": True, "pullback_breakout_retake_clearance_pct": 0.0, "pullback_reclaim_confirm_rel_vol": 1.15, "pullback_stop_mode": "pullback_low", "max_simultaneous_entries": 3, "prior_event_lookback_days": 10, } pullback_hypotheses = [ { **_pullback_base, "atr_stop_multiplier": 0.80, "trailing_at_r": 1.0, "trailing_stop_atr_multiplier": 0.8, "trailing_tighten_at_r": 2.0, "trailing_stop_atr_multiplier_tight": 0.30, "min_candidate_breadth": 0.50, "market_regime_spy_threshold": 0.0015, "max_gap_zscore_20d": 2.5, "min_obv_slope_20d": 0.0, }, { **_pullback_base, "atr_stop_multiplier": 0.90, "trailing_at_r": 1.0, "trailing_stop_atr_multiplier": 0.7, "trailing_tighten_at_r": 2.0, "trailing_stop_atr_multiplier_tight": 0.25, "pullback_depth_max_pct": 0.50, "pullback_volume_contraction_ratio": 0.80, "pullback_reclaim_confirm_rel_vol": 1.25, "pullback_breakout_retake_clearance_pct": 0.001, "min_candidate_breadth": 0.45, "market_regime_spy_threshold": 0.0, "max_gap_zscore_20d": 2.0, }, { **_pullback_base, "atr_stop_multiplier": 0.75, "trailing_at_r": 1.0, "trailing_stop_atr_multiplier": 0.9, "trailing_tighten_at_r": 2.5, "trailing_stop_atr_multiplier_tight": 0.35, "weight_event_catalyst": 0.12, "weight_close_location": 0.00, "pullback_impulse_min_move_atr": 0.45, "pullback_max_bars": 6, "pullback_reclaim_confirm_rel_vol": 1.20, "min_candidate_breadth": 0.60, "market_regime_spy_threshold": 0.0015, "max_gap_zscore_20d": 2.5, "min_obv_slope_20d": 0.0, }, { **_pullback_base, "atr_stop_multiplier": 1.00, "trailing_at_r": 1.5, "trailing_stop_atr_multiplier": 0.8, "trailing_tighten_at_r": 3.0, "trailing_stop_atr_multiplier_tight": 0.4, "pullback_stop_mode": "vwap_lower", "pullback_stop_vwap_buffer_pct": 0.002, "pullback_impulse_window_end_min": 35, "pullback_depth_max_pct": 0.55, "pullback_reclaim_confirm_rel_vol": 1.10, "min_candidate_breadth": 0.40, "market_regime_spy_threshold": 0.0, "max_gap_pct": 0.04, "max_gap_zscore_20d": 2.5, }, ] _vwap_reclaim_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": 0.08, "min_abs_gap_pct": 0.015, "min_premarket_dollar_vol": 1_000_000.0, "max_candidates": 18, "max_candidates_per_sector": 3, "min_candidates_to_trade": 1, "ticker_cooldown_days": 0, "weight_rvol": 0.30, "weight_gap": 0.15, "weight_dollar_vol": 0.05, "weight_premarket_dollar_vol": 0.25, "weight_body_ratio": 0.0, "weight_momentum": 0.10, "weight_close_location": -0.15, "weight_gap_zscore": 0.05, "weight_obv_slope": 0.05, "weight_entropy": -0.05, "allow_doji_breakout": True, "allow_red_to_green_breakout": True, "min_body_ratio": 0.0, "vwap_reclaim_require_prior_dip": True, "vwap_reclaim_window_start_min": 20, "vwap_reclaim_window_end_min": 90, "vwap_reclaim_min_clearance_pct": 0.002, "vwap_reclaim_require_orb_open_retake": True, "vwap_reclaim_confirm_rel_vol": 1.2, "max_simultaneous_entries": 3, } vwap_reclaim_hypotheses = [ { **_vwap_reclaim_base, "atr_stop_multiplier": 0.90, "trailing_at_r": 1.5, "trailing_stop_atr_multiplier": 0.8, "trailing_tighten_at_r": 2.5, "trailing_stop_atr_multiplier_tight": 0.35, "min_candidate_breadth": 0.40, "market_regime_spy_threshold": 0.0, "max_gap_pct": 0.06, "max_gap_zscore_20d": 2.5, }, { **_vwap_reclaim_base, "atr_stop_multiplier": 1.0, "trailing_at_r": 1.0, "trailing_stop_atr_multiplier": 0.7, "trailing_tighten_at_r": 2.0, "trailing_stop_atr_multiplier_tight": 0.30, "vwap_reclaim_window_start_min": 15, "vwap_reclaim_window_end_min": 75, "vwap_reclaim_min_clearance_pct": 0.003, "vwap_reclaim_stop_mode": "vwap", "vwap_reclaim_confirm_rel_vol": 1.35, "min_candidate_breadth": 0.35, "market_regime_spy_threshold": 0.0, "max_gap_pct": 0.06, "max_gap_zscore_20d": 2.0, }, { **_vwap_reclaim_base, "atr_stop_multiplier": 1.0, "trailing_at_r": 1.0, "trailing_stop_atr_multiplier": 0.8, "trailing_tighten_at_r": 2.0, "trailing_stop_atr_multiplier_tight": 0.3, "weight_event_catalyst": 0.10, "prior_event_lookback_days": 10, "min_candidate_breadth": 0.50, "market_regime_spy_threshold": 0.0015, "max_gap_pct": 0.08, "max_gap_zscore_20d": 3.0, "min_obv_slope_20d": 0.0, }, { **_vwap_reclaim_base, "atr_stop_multiplier": 1.25, "trailing_at_r": 2.0, "trailing_stop_atr_multiplier": 0.9, "trailing_tighten_at_r": 3.0, "trailing_stop_atr_multiplier_tight": 0.4, "vwap_reclaim_window_start_min": 30, "vwap_reclaim_window_end_min": 120, "vwap_reclaim_confirm_rel_vol": 1.1, "weight_close_location": -0.10, "weight_entropy": -0.03, "min_candidate_breadth": 0.30, "market_regime_spy_threshold": -0.002, "max_gap_pct": 0.10, }, ] 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="orb_pullback_v1", thesis="Leader-style ORB engine that skips the first breakout and instead buys only orderly pullback resumptions back through the ORB high.", live_readiness="research_only", base_overrides={ "engine_family": "orb_pullback_v1", "live_readiness": "research_only", "entry_direction": "long_only", "weight_body_ratio": 0.0, "min_body_ratio": 0.0, }, hypotheses=pullback_hypotheses, ), EngineSpec( family="vwap_reclaim_v1", thesis="Delayed continuation engine that waits for a morning dip below VWAP, then buys the first clean reclaim in high-attention gap leaders.", live_readiness="research_only", base_overrides={ "engine_family": "vwap_reclaim_v1", "live_readiness": "research_only", "entry_direction": "long_only", "weight_body_ratio": 0.0, "min_body_ratio": 0.0, }, hypotheses=vwap_reclaim_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()