You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1710 lines
67 KiB
Python

"""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()