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