|
|
#!/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
|
|
|
# ════════════════════════════════<E29590><E29590>══════════════════════════════════════
|
|
|
|
|
|
# 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,
|
|
|
)
|
|
|
|
|
|
# ════════<E29590><E29590><EFBFBD>═══════════════════════════════════════════════════<E29590><E29590>══════════
|
|
|
# B. Score-Based Position Sizing — top picks get larger positions
|
|
|
# ═════════════<E29590><E29590>═══════════════════════════════════<E29590><E29590>═════════════════════
|
|
|
|
|
|
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,
|
|
|
)
|
|
|
|
|
|
# ════<E29590><E29590><EFBFBD>════════════<E29590><E29590>══════════════════════════════<E29590><E29590>══════════════════════
|
|
|
# C. Confirmation Bar — require follow-through after breakout
|
|
|
# ═════════════════<E29590><E29590><EFBFBD>═════════════════════════════════════════════════════
|
|
|
|
|
|
results["V13c_confirm"] = run_experiment(
|
|
|
"V13c: Require confirmation bar (next bar must close above entry)",
|
|
|
{"require_confirmation_bar": True}, days,
|
|
|
)
|
|
|
|
|
|
# ═<><E29590><EFBFBD>════════════<E29590><E29590>════════════════════════════════════════════════════════
|
|
|
# D. Gap Fill Protection — exit when catalyst is rejected
|
|
|
# ════<E29590><E29590>════════════════<E29590><E29590><EFBFBD>═════════════════════════════════════════════════
|
|
|
|
|
|
results["V13d_gap_fill"] = run_experiment(
|
|
|
"V13d: Gap fill exit (close < prev_close → exit immediately)",
|
|
|
{"exit_on_gap_fill": True}, days,
|
|
|
)
|
|
|
|
|
|
# ══════════════════════════<E29590><E29590>════════════════════════════════════════════
|
|
|
# E. Max Hold Time — capture morning momentum, avoid afternoon fade
|
|
|
# ══════════════<E29590><E29590><EFBFBD>════════════════════════════════<E29590><E29590>═══════════════════════
|
|
|
|
|
|
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,
|
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════<E29590><E29590><EFBFBD>═══════
|
|
|
# F. Combinations — combine promising features
|
|
|
# ══════════════════<E29590><E29590><EFBFBD>═══════════════════════════<E29590><E29590><EFBFBD>════════════════════════
|
|
|
|
|
|
# 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,
|
|
|
)
|
|
|
|
|
|
# <20><>══════════════════════════════════════════════════════════════════════
|
|
|
# Summary table
|
|
|
# ════════════<E29590><E29590><EFBFBD>═══════════════════<E29590><E29590><EFBFBD>══════════════════════════════════════
|
|
|
|
|
|
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()
|