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.

428 lines
16 KiB
Python

"""ORB strategy overfitting analysis with streaming intraday simulation."""
from __future__ import annotations
import argparse
import asyncio
import json
import statistics
import sys
import time
from pathlib import Path
from typing import Any
import yaml
from apps.intraday_bt.oracle import make_intraday_oracle_client
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from libs.backtest.domain import SplitResult, WalkForwardSummary
from libs.common.config import get_settings
from libs.intraday.domain import ORBStrategyParams
from libs.oracle_client import OracleClient
from apps.intraday_bt.orb_research import (
build_orb_research_context,
force_simple_returns,
generate_walk_forward_windows,
resolve_orb_config,
simulate_orb_period,
split_trading_days,
)
from apps.intraday_bt.run import _latest_backtest_date
_console = Console(width=120)
_DEFAULT_SPLIT_DATE = "2026-01-01"
_DEFAULT_START_DATE = "2022-01-01"
_WF_TRAIN_DAYS = 252
_WF_TEST_DAYS = 63
async def run_is_oos_test(
context,
client: OracleClient,
config,
split_date: str,
) -> dict[str, Any]:
train_days, test_days = split_trading_days(context.trading_days, split_date=split_date)
if not train_days or not test_days:
return {"verdict": "SKIP", "notes": "Not enough data to split"}
orb_params = config.orb_strategy or ORBStrategyParams()
is_metrics = await simulate_orb_period(context, client, orb_params, train_days, run_id="is")
oos_metrics = await simulate_orb_period(context, client, orb_params, test_days, run_id="oos")
is_sharpe = is_metrics.sharpe_ratio or 0.0
oos_sharpe = oos_metrics.sharpe_ratio or 0.0
retention = 0.0 if is_sharpe <= 0 else oos_sharpe / is_sharpe
if retention >= 0.60:
verdict = "PASS"
elif retention >= 0.40:
verdict = "WARN"
else:
verdict = "FAIL"
return {
"verdict": verdict,
"is_sharpe": round(is_sharpe, 3),
"oos_sharpe": round(oos_sharpe, 3),
"retention_pct": round(retention * 100, 1),
"is_period": f"{train_days[0]}{train_days[-1]} ({len(train_days)} days)",
"oos_period": f"{test_days[0]}{test_days[-1]} ({len(test_days)} days)",
}
def summarize_is_oos_from_results(
is_result: SplitResult,
oos_result: SplitResult,
*,
is_period: str | None = None,
oos_period: str | None = None,
) -> dict[str, Any]:
"""Reuse already-computed train/test split results for IS/OOS retention."""
is_sharpe = is_result.sharpe_ratio or 0.0
oos_sharpe = oos_result.sharpe_ratio or 0.0
retention = 0.0 if is_sharpe <= 0 else oos_sharpe / is_sharpe
if retention >= 0.60:
verdict = "PASS"
elif retention >= 0.40:
verdict = "WARN"
else:
verdict = "FAIL"
payload = {
"verdict": verdict,
"is_sharpe": round(is_sharpe, 3),
"oos_sharpe": round(oos_sharpe, 3),
"retention_pct": round(retention * 100, 1),
"source": "split_results",
}
if is_period is not None:
payload["is_period"] = is_period
if oos_period is not None:
payload["oos_period"] = oos_period
return payload
async def run_walk_forward_test(
context,
client: OracleClient,
config,
train_days: int = _WF_TRAIN_DAYS,
test_days: int = _WF_TEST_DAYS,
) -> dict[str, Any]:
windows = generate_walk_forward_windows(context.trading_days, train_days, test_days)
if len(windows) < 2:
return {"verdict": "SKIP", "notes": f"Need ≥ {train_days + 2 * test_days} days, got {len(context.trading_days)}"}
orb_params = config.orb_strategy or ORBStrategyParams()
sharpes: list[float] = []
for i, (_, wf_test) in enumerate(windows, start=1):
metrics = await simulate_orb_period(context, client, orb_params, wf_test, run_id=f"wf{i:02d}")
sharpe = metrics.sharpe_ratio or 0.0
sharpes.append(sharpe)
sys.stdout.write(f"\r WF window {i}/{len(windows)}: test Sharpe={sharpe:.2f} ")
sys.stdout.flush()
print()
mean_sr = statistics.mean(sharpes)
std_sr = statistics.stdev(sharpes) if len(sharpes) > 1 else 0.0
cv = std_sr / abs(mean_sr) if abs(mean_sr) > 0.01 else float("inf")
n_positive = sum(1 for sharpe in sharpes if sharpe > 0)
if mean_sr > 0.5 and cv < 0.80:
verdict = "PASS"
elif mean_sr > 0 and cv < 1.5:
verdict = "WARN"
else:
verdict = "FAIL"
return {
"verdict": verdict,
"n_windows": len(windows),
"mean_sharpe": round(mean_sr, 3),
"std_sharpe": round(std_sr, 3),
"cv": round(cv, 3),
"n_positive": n_positive,
"window_sharpes": [round(s, 3) for s in sharpes],
}
def summarize_walk_forward_test_from_summary(wf_summary: WalkForwardSummary) -> dict[str, Any]:
"""Reuse an already-built walk-forward summary for the overfit verdict."""
sharpes = [fold.test_metrics.sharpe_ratio or 0.0 for fold in wf_summary.folds]
if len(sharpes) < 2:
return {
"verdict": "SKIP",
"notes": f"Need ≥ 2 folds, got {len(sharpes)}",
}
mean_sr = statistics.mean(sharpes)
std_sr = statistics.stdev(sharpes) if len(sharpes) > 1 else 0.0
cv = std_sr / abs(mean_sr) if abs(mean_sr) > 0.01 else float("inf")
n_positive = sum(1 for sharpe in sharpes if sharpe > 0)
if mean_sr > 0.5 and cv < 0.80:
verdict = "PASS"
elif mean_sr > 0 and cv < 1.5:
verdict = "WARN"
else:
verdict = "FAIL"
return {
"verdict": verdict,
"n_windows": len(sharpes),
"mean_sharpe": round(mean_sr, 3),
"std_sharpe": round(std_sr, 3),
"cv": round(cv, 3),
"n_positive": n_positive,
"window_sharpes": [round(s, 3) for s in sharpes],
"source": "walk_forward_summary",
}
async def run_param_plateau_test(
context,
client: OracleClient,
config,
quick: bool = False,
param_names: list[str] | None = None,
) -> dict[str, Any]:
orb_params = config.orb_strategy or ORBStrategyParams()
n_values = 3 if quick else 5
params_to_test = [
("atr_stop_multiplier", orb_params.atr_stop_multiplier, 0.4),
("breakeven_at_r", orb_params.breakeven_at_r, 0.4),
("trailing_stop_atr_multiplier", orb_params.trailing_stop_atr_multiplier, 0.4),
]
if param_names is not None:
selected = set(param_names)
params_to_test = [item for item in params_to_test if item[0] in selected]
results = []
for param_name, base_value, spread in params_to_test:
lo = base_value * (1 - spread)
hi = base_value * (1 + spread)
test_values = [lo + (hi - lo) * i / (n_values - 1) for i in range(n_values)]
sharpes: list[float] = []
for value in test_values:
modified = orb_params.model_copy(update={param_name: round(value, 6)})
metrics = await simulate_orb_period(
context,
client,
modified,
context.trading_days,
run_id=f"{param_name[:4]}_{value:.4f}",
)
sharpe = metrics.sharpe_ratio or 0.0
sharpes.append(sharpe)
sys.stdout.write(f"\r {param_name}={value:.4f} → Sharpe={sharpe:.2f} ")
sys.stdout.flush()
print()
mean_sharpe = statistics.mean(sharpes)
plateau = max(0.0, 1.0 - statistics.stdev(sharpes) / abs(mean_sharpe)) if len(sharpes) > 1 and abs(mean_sharpe) > 0.01 else 0.0
verdict = "PASS" if plateau >= 0.70 else "WARN" if plateau >= 0.40 else "FAIL"
results.append({
"param": param_name,
"base_value": base_value,
"test_values": [round(v, 5) for v in test_values],
"sharpe_values": [round(s, 3) for s in sharpes],
"plateau": round(plateau, 3),
"verdict": verdict,
})
verdicts = [result["verdict"] for result in results]
if all(v == "PASS" for v in verdicts):
overall = "PASS"
elif "FAIL" in verdicts:
overall = "FAIL"
else:
overall = "WARN"
return {"verdict": overall, "params": results}
async def run_permutation_test(
context,
client: OracleClient,
config,
n_permutations: int = 30,
) -> dict[str, Any]:
orb_params = config.orb_strategy or ORBStrategyParams()
observed = await simulate_orb_period(context, client, orb_params, context.trading_days, run_id="perm_obs")
observed_sharpe = observed.sharpe_ratio or 0.0
_console.print(f" [dim]Observed Sharpe (real ranking): {observed_sharpe:.3f}[/dim]")
null_sharpes: list[float] = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
"{task.completed}/{task.total}",
TimeElapsedColumn(),
console=_console,
transient=True,
) as progress:
task = progress.add_task("Permutation test", total=n_permutations)
for i in range(n_permutations):
metrics = await simulate_orb_period(
context,
client,
orb_params,
context.trading_days,
run_id=f"perm{i:03d}",
shuffle_candidates_seed=42 + i,
)
null_sharpes.append(metrics.sharpe_ratio or 0.0)
progress.advance(task)
p_value = sum(1 for sr in null_sharpes if sr >= observed_sharpe) / max(len(null_sharpes), 1)
null_sorted = sorted(null_sharpes)
p95_idx = min(len(null_sorted) - 1, int(0.95 * (len(null_sorted) - 1))) if null_sorted else 0
null_p95 = null_sorted[p95_idx] if null_sorted else 0.0
null_median = statistics.median(null_sharpes) if null_sharpes else 0.0
if p_value < 0.05:
verdict = "PASS"
elif p_value < 0.20:
verdict = "WARN"
else:
verdict = "FAIL"
return {
"verdict": verdict,
"observed_sharpe": round(observed_sharpe, 3),
"n_permutations": n_permutations,
"null_median": round(null_median, 3),
"null_p95": round(null_p95, 3),
"p_value": round(p_value, 4),
"null_sharpes": [round(sr, 3) for sr in null_sharpes],
}
def _vc(verdict: str) -> str:
return {"PASS": "green", "WARN": "yellow", "FAIL": "red", "SKIP": "dim"}.get(verdict, "white")
def _verdict_tag(verdict: str) -> str:
color = _vc(verdict)
return f"[{color}][{verdict}][/{color}]"
def _print_report(
config_path: Path,
test1: dict[str, Any],
test2: dict[str, Any],
test3: dict[str, Any],
test4: dict[str, Any],
elapsed: float,
) -> None:
weights = {"PASS": 1.0, "WARN": 0.5, "FAIL": 0.0, "SKIP": None}
scores = [weights[t.get("verdict", "SKIP")] for t in [test1, test2, test3, test4]]
scores = [score for score in scores if score is not None]
overall_score = int(statistics.mean(scores) * 100) if scores else 0
pass_count = sum(1 for t in [test1, test2, test3, test4] if t.get("verdict") == "PASS")
overall_verdict = "PASS" if pass_count >= 3 else "WARN" if pass_count >= 2 else "FAIL"
color = _vc(overall_verdict)
_console.print()
_console.print(
Panel(
f"[bold]ORB OVERFITTING ANALYSIS[/bold]\n"
f"Config: [cyan]{config_path}[/cyan]\n\n"
f"Overall: [{color} bold]{overall_verdict}[/{color} bold] "
f"Score: [bold]{overall_score}/100[/bold] ({elapsed:.0f}s)",
box=box.DOUBLE,
width=100,
)
)
for label, payload in [
("1. IS/OOS Retention", test1),
("2. Walk-Forward Stability", test2),
("3. Parameter Plateau", test3),
("4. Candidate Ranking Permutation", test4),
]:
_console.print(f"\n [bold]{label}[/bold] {_verdict_tag(payload.get('verdict', 'SKIP'))}")
_console.print(f" {json.dumps(payload, ensure_ascii=True)}")
async def _async_main(args: argparse.Namespace) -> int:
t0 = time.time()
config_path, config = resolve_orb_config(args.config)
config = force_simple_returns(config)
settings = get_settings()
async with make_intraday_oracle_client(settings) as client:
context = await build_orb_research_context(
config,
args.start,
args.end or _latest_backtest_date().isoformat(),
client,
print_progress=True,
)
skip = set(args.skip.split(",")) if args.skip else set()
test1: dict[str, Any] = {"verdict": "SKIP"}
test2: dict[str, Any] = {"verdict": "SKIP"}
test3: dict[str, Any] = {"verdict": "SKIP"}
test4: dict[str, Any] = {"verdict": "SKIP"}
if "is_oos" not in skip:
_console.print("\n[bold][1/4] IS/OOS Retention...[/bold]")
test1 = await run_is_oos_test(context, client, config, args.split_date)
if "wf" not in skip:
_console.print("\n[bold][2/4] Walk-Forward Stability...[/bold]")
wf_train = 126 if args.quick else _WF_TRAIN_DAYS
wf_test = 42 if args.quick else _WF_TEST_DAYS
test2 = await run_walk_forward_test(context, client, config, train_days=wf_train, test_days=wf_test)
if "plateau" not in skip:
_console.print("\n[bold][3/4] Parameter Plateau...[/bold]")
test3 = await run_param_plateau_test(context, client, config, quick=args.quick)
if "perm" not in skip:
_console.print(f"\n[bold][4/4] Candidate Permutation (N={args.permutations})...[/bold]")
test4 = await run_permutation_test(context, client, config, n_permutations=args.permutations)
elapsed = time.time() - t0
_print_report(config_path, test1, test2, test3, test4, elapsed)
if args.output_json:
report = {
"config": str(config_path),
"period": f"{context.trading_days[0]}{context.trading_days[-1]}",
"split_date": args.split_date,
"elapsed_seconds": round(elapsed, 1),
"tests": {
"is_oos": test1,
"walk_forward": test2,
"param_plateau": test3,
"permutation": test4,
},
}
Path(args.output_json).parent.mkdir(parents=True, exist_ok=True)
Path(args.output_json).write_text(json.dumps(report, indent=2))
_console.print(f"\n[dim]Report saved → {args.output_json}[/dim]")
return 0
def main() -> None:
parser = argparse.ArgumentParser(
prog="fithia2 intraday-overfit-check",
description="ORB strategy overfitting analysis (IS/OOS, WFV, param plateau, permutation)",
)
parser.add_argument("--config", "-c", required=True, help="YAML config path or strategy slug")
parser.add_argument("--split-date", default=_DEFAULT_SPLIT_DATE, help=f"IS/OOS split date (default: {_DEFAULT_SPLIT_DATE})")
parser.add_argument("--start", default=_DEFAULT_START_DATE, help=f"Start of backtest window (default: {_DEFAULT_START_DATE})")
parser.add_argument("--end", default=None, help="End of backtest window (default: latest available)")
parser.add_argument("--quick", action="store_true", help="Quick mode: fewer WF windows, 3-point plateau, 15 permutations")
parser.add_argument("--permutations", type=int, default=50, help="Candidate permutation test iterations")
parser.add_argument("--skip", default="", help="Comma-separated tests to skip: is_oos,wf,plateau,perm")
parser.add_argument("--output-json", default=None, help="Save report as JSON to this path")
args = parser.parse_args()
if args.quick:
args.permutations = 15
raise SystemExit(asyncio.run(_async_main(args)))
if __name__ == "__main__":
main()