#!/usr/bin/env python3 """V13 experiment runner: new engine features for exit timing and candidate selection. Based on V11 champion. Tests: A. VWAP trailing exit (exit/floor modes with buffer/activation variants) B. Score-based position sizing (top picks get larger positions) C. Confirmation bar (filter false breakouts) D. Gap fill protection (exit when gap is fully filled) E. Max hold time (morning momentum capture, exit before afternoon fade) F. Combinations of winners Usage: python -u scripts/v13_experiments.py [--days N] """ import subprocess import sys import tempfile import yaml from pathlib import Path BASE_CONFIG = "configs/intraday/strategies/orb_gainers_v11.yaml" DAYS = 200 def load_base(): with open(BASE_CONFIG) as f: return yaml.safe_load(f) def run_experiment(name: str, overrides: dict, days: int = DAYS) -> dict | None: """Run a single backtest with config overrides and return parsed metrics.""" cfg = load_base() for key, val in overrides.items(): cfg["orb_strategy"][key] = val with tempfile.NamedTemporaryFile( mode="w", suffix=".yaml", prefix="v13_", delete=False, dir="/tmp" ) as f: yaml.dump(cfg, f, default_flow_style=False) tmp_path = f.name cmd = [ sys.executable, "-u", "-m", "apps.intraday_bt.run", "--config", tmp_path, "--days", str(days), ] print(f"\n{'='*70}") print(f" {name}") print(f" Overrides: {overrides or '(V11 baseline)'}") print(f"{'='*70}", flush=True) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=900) except subprocess.TimeoutExpired: print(f" TIMEOUT after 900s") return None finally: Path(tmp_path).unlink(missing_ok=True) if result.returncode != 0: print(f" FAILED (rc={result.returncode})") if result.stderr: print(f" stderr: {result.stderr[-500:]}") return None output = result.stdout metrics = {} for line in output.split("\n"): line = line.strip() if "Total return" in line and "%" in line: try: val = line.split("│")[-2].strip().replace("%", "").replace("+", "") metrics["total_return"] = float(val) except (ValueError, IndexError): pass elif "Max drawdown" in line and "%" in line: try: val = line.split("│")[-2].strip().replace("%", "").replace("+", "") metrics["max_drawdown"] = float(val) except (ValueError, IndexError): pass elif "Sharpe ratio" in line: try: val = line.split("│")[-2].strip() metrics["sharpe"] = float(val) except (ValueError, IndexError): pass elif "Total trades" in line: try: val = line.split("│")[-2].strip() metrics["trades"] = int(val) except (ValueError, IndexError): pass elif "Win rate" in line and "%" in line: try: val = line.split("│")[-2].strip().replace("%", "").replace("+", "") metrics["win_rate"] = float(val) except (ValueError, IndexError): pass elif "Profit factor" in line: try: val = line.split("│")[-2].strip() metrics["profit_factor"] = float(val) except (ValueError, IndexError): pass if not metrics: print(" WARNING: Could not parse metrics from output") for line in output.split("\n")[-30:]: print(f" {line}") return None print(f" => return={metrics.get('total_return', '?'):+.2f}%, " f"DD={metrics.get('max_drawdown', '?'):.2f}%, " f"Sharpe={metrics.get('sharpe', '?'):.2f}, " f"trades={metrics.get('trades', '?')}, " f"WR={metrics.get('win_rate', '?'):.1f}%, " f"PF={metrics.get('profit_factor', '?'):.3f}", flush=True) return metrics def main(): days = DAYS if "--days" in sys.argv: idx = sys.argv.index("--days") days = int(sys.argv[idx + 1]) results = {} # V11 baseline (no overrides) results["V11_baseline"] = run_experiment("V11 Baseline (control)", {}, days) # ═══════════════════════════════════════════════════════════════════════ # A. VWAP Exit — running VWAP as dynamic support/resistance # ════════════════════════════════��══════════════════════════════════════ # A1: Simple VWAP cross exit (exit when close < VWAP) results["V13a1_vwap_exit"] = run_experiment( "V13a1: VWAP exit (close < VWAP → exit)", {"vwap_exit_mode": "exit"}, days, ) # A2: VWAP exit with buffer (close < VWAP - 0.3ATR → exit) results["V13a2_vwap_buf03"] = run_experiment( "V13a2: VWAP exit with 0.3ATR buffer", {"vwap_exit_mode": "exit", "vwap_exit_buffer_atr": 0.3}, days, ) # A3: VWAP exit only after 1R reached (avoid premature exit) results["V13a3_vwap_after1r"] = run_experiment( "V13a3: VWAP exit after reaching 1R", {"vwap_exit_mode": "exit", "vwap_exit_after_r": 1.0}, days, ) # A4: VWAP exit after 1R with buffer results["V13a4_vwap_1r_buf"] = run_experiment( "V13a4: VWAP exit after 1R + 0.3ATR buffer", {"vwap_exit_mode": "exit", "vwap_exit_after_r": 1.0, "vwap_exit_buffer_atr": 0.3}, days, ) # A5: VWAP as trailing stop floor (VWAP raises the trailing stop minimum) results["V13a5_vwap_floor"] = run_experiment( "V13a5: VWAP floor (trailing stop can't go below VWAP)", {"vwap_exit_mode": "floor"}, days, ) # A6: VWAP floor with buffer results["V13a6_vwap_floor_buf"] = run_experiment( "V13a6: VWAP floor with 0.3ATR buffer", {"vwap_exit_mode": "floor", "vwap_exit_buffer_atr": 0.3}, days, ) # ════════���═══════════════════════════════════════════════════��══════════ # B. Score-Based Position Sizing — top picks get larger positions # ═════════════��═══════════════════════════════════��═════════════════════ results["V13b1_score_1.5x"] = run_experiment( "V13b1: Score sizing 1.5x (top pick = 1.5× base risk)", {"score_sizing_multiplier": 1.5}, days, ) results["V13b2_score_2.0x"] = run_experiment( "V13b2: Score sizing 2.0x (top pick = 2× base risk)", {"score_sizing_multiplier": 2.0}, days, ) results["V13b3_score_3.0x"] = run_experiment( "V13b3: Score sizing 3.0x (top pick = 3× base risk)", {"score_sizing_multiplier": 3.0}, days, ) # ════���════════════��══════════════════════════════��══════════════════════ # C. Confirmation Bar — require follow-through after breakout # ═════════════════���═════════════════════════════════════════════════════ results["V13c_confirm"] = run_experiment( "V13c: Require confirmation bar (next bar must close above entry)", {"require_confirmation_bar": True}, days, ) # ═���════════════��════════════════════════════════════════════════════════ # D. Gap Fill Protection — exit when catalyst is rejected # ════��════════════════���═════════════════════════════════════════════════ results["V13d_gap_fill"] = run_experiment( "V13d: Gap fill exit (close < prev_close → exit immediately)", {"exit_on_gap_fill": True}, days, ) # ══════════════════════════��════════════════════════════════════════════ # E. Max Hold Time — capture morning momentum, avoid afternoon fade # ══════════════���════════════════════════════════��═══════════════════════ results["V13e1_hold_60"] = run_experiment( "V13e1: Max hold 60min (1 hour)", {"max_hold_minutes": 60}, days, ) results["V13e2_hold_90"] = run_experiment( "V13e2: Max hold 90min (1.5 hours)", {"max_hold_minutes": 90}, days, ) results["V13e3_hold_120"] = run_experiment( "V13e3: Max hold 120min (2 hours)", {"max_hold_minutes": 120}, days, ) results["V13e4_hold_180"] = run_experiment( "V13e4: Max hold 180min (3 hours)", {"max_hold_minutes": 180}, days, ) # ═══════════════════════════════════════════════════════════════���═══════ # F. Combinations — combine promising features # ══════════════════���═══════════════════════════���════════════════════════ # F1: VWAP exit + score sizing results["V13f1_vwap_score"] = run_experiment( "V13f1: VWAP exit after 1R + score sizing 2x", {"vwap_exit_mode": "exit", "vwap_exit_after_r": 1.0, "score_sizing_multiplier": 2.0}, days, ) # F2: VWAP exit + confirmation bar results["V13f2_vwap_confirm"] = run_experiment( "V13f2: VWAP exit after 1R + confirmation bar", {"vwap_exit_mode": "exit", "vwap_exit_after_r": 1.0, "require_confirmation_bar": True}, days, ) # F3: VWAP exit + gap fill + max hold 120 results["V13f3_vwap_gap_hold"] = run_experiment( "V13f3: VWAP exit after 1R + gap fill + max hold 120", { "vwap_exit_mode": "exit", "vwap_exit_after_r": 1.0, "exit_on_gap_fill": True, "max_hold_minutes": 120, }, days, ) # F4: Score sizing + confirmation + gap fill results["V13f4_score_confirm_gap"] = run_experiment( "V13f4: Score 2x + confirmation + gap fill", {"score_sizing_multiplier": 2.0, "require_confirmation_bar": True, "exit_on_gap_fill": True}, days, ) # F5: Kitchen sink — all features results["V13f5_kitchen_sink"] = run_experiment( "V13f5: All features combined", { "vwap_exit_mode": "exit", "vwap_exit_after_r": 1.0, "vwap_exit_buffer_atr": 0.3, "score_sizing_multiplier": 2.0, "require_confirmation_bar": True, "exit_on_gap_fill": True, "max_hold_minutes": 120, }, days, ) # ��══════════════════════════════════════════════════════════════════════ # Summary table # ════════════���═══════════════════���══════════════════════════════════════ print(f"\n\n{'='*95}") print(" V13 EXPERIMENT RESULTS SUMMARY (base: V11 champion)") print(f"{'='*95}") print(f"{'Experiment':<30} {'Return':>9} {'DD':>9} {'Sharpe':>7} {'Trades':>7} {'WR':>7} {'PF':>7}") print(f"{'-'*30} {'-'*9} {'-'*9} {'-'*7} {'-'*7} {'-'*7} {'-'*7}") baseline_ret = (results.get("V11_baseline") or {}).get("total_return", 0) for name, m in results.items(): if m is None: print(f"{name:<30} {'FAILED':>9}") continue ret = m.get("total_return", 0) delta = ret - baseline_ret ret_str = f"{ret:+.2f}%" dd = f"{m.get('max_drawdown', 0):.2f}%" sh = f"{m.get('sharpe', 0):.2f}" tr = f"{m.get('trades', 0)}" wr = f"{m.get('win_rate', 0):.1f}%" pf = f"{m.get('profit_factor', 0):.3f}" marker = " <== BASE" if name == "V11_baseline" else (f" ({delta:+.2f}pp)" if delta != 0 else "") print(f"{name:<30} {ret_str:>9} {dd:>9} {sh:>7} {tr:>7} {wr:>7} {pf:>7}{marker}") if __name__ == "__main__": main()