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.
266 lines
9.3 KiB
Python
266 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Composite performance evaluator: run one or more primary strategies with
|
|
idle-alpha sleeve + cash parking presets and compare results side-by-side.
|
|
|
|
Usage:
|
|
python -m apps.tools.run_composite_eval \\
|
|
--manifests configs/experiments/return_max_long_v7.70.json \\
|
|
configs/experiments/return_max_long_v18.250.json \\
|
|
--parking qqqm_low_dd \\
|
|
--idle-alpha micro_event_alpha_plus_event_plus \\
|
|
--output-root /tmp/composite_eval
|
|
|
|
Compares composite CW, Sharpe, DD, idle fraction, and amplification factor.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_SNAPSHOT_DIR = ""
|
|
DEFAULT_OUTPUT_ROOT = "/tmp/composite_eval"
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Run multiple experiments with IA+parking presets and compare composite results."
|
|
)
|
|
parser.add_argument(
|
|
"--manifests", nargs="+", required=True,
|
|
help="Experiment manifest paths (can use experiment names like 'return_max_long_v7.70').",
|
|
)
|
|
parser.add_argument(
|
|
"--parking", default="qqqm_low_dd",
|
|
help="Cash parking preset name (default: qqqm_low_dd).",
|
|
)
|
|
parser.add_argument(
|
|
"--idle-alpha", default="micro_event_alpha_plus_event_plus",
|
|
help="Idle alpha sleeve preset name (default: micro_event_alpha_plus_event_plus).",
|
|
)
|
|
parser.add_argument(
|
|
"--no-presets", action="store_true",
|
|
help="Run without any presets (primary-only mode for baseline comparison).",
|
|
)
|
|
parser.add_argument(
|
|
"--split", default="all",
|
|
help="Backtest split (default: 'all' = full period merging train+valid+test).",
|
|
)
|
|
parser.add_argument(
|
|
"--snapshot-dir", default=DEFAULT_SNAPSHOT_DIR,
|
|
help=f"Snapshot root directory (default: {DEFAULT_SNAPSHOT_DIR}).",
|
|
)
|
|
parser.add_argument(
|
|
"--output-root", default=DEFAULT_OUTPUT_ROOT,
|
|
help=f"Root directory for run artifacts (default: {DEFAULT_OUTPUT_ROOT}).",
|
|
)
|
|
parser.add_argument(
|
|
"--sort", choices=["cw", "sharpe", "dd", "amp", "idle"], default="cw",
|
|
help="Sort results by this column (default: cw).",
|
|
)
|
|
parser.add_argument(
|
|
"--idle-alpha-dedup", default=None, choices=["skip", "rename"],
|
|
help="IA dedup mode: 'rename' injects IA engines even when engine_id conflicts with primary (adds __ia_sleeve suffix).",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _resolve_manifest_path(manifest: str) -> str:
|
|
"""Resolve experiment name or path to a JSON file path."""
|
|
p = Path(manifest)
|
|
if p.exists():
|
|
return str(p)
|
|
# Try configs/experiments/return_max_long_<name>.json and bare name
|
|
candidates = [
|
|
REPO_ROOT / "configs" / "experiments" / f"{manifest}.json",
|
|
REPO_ROOT / "configs" / "experiments" / f"return_max_long_{manifest}.json",
|
|
]
|
|
for c in candidates:
|
|
if c.exists():
|
|
return str(c)
|
|
return manifest # pass through; run.py will error if not found
|
|
|
|
|
|
def run_composite(
|
|
manifest: str,
|
|
parking_preset: str | None,
|
|
idle_alpha_preset: str | None,
|
|
split: str | None,
|
|
output_root: Path,
|
|
snapshot_dir: str,
|
|
idle_alpha_dedup: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Run a single backtest with presets and return key metrics."""
|
|
exp_name = Path(manifest).stem
|
|
run_output = output_root / exp_name
|
|
run_output.mkdir(parents=True, exist_ok=True)
|
|
|
|
cmd = [
|
|
sys.executable, "-m", "apps.backtester.run",
|
|
"--manifest", manifest,
|
|
"--output-root", str(run_output),
|
|
]
|
|
if snapshot_dir:
|
|
cmd += ["--snapshot-dir", snapshot_dir]
|
|
if split:
|
|
cmd += ["--split", split]
|
|
if parking_preset:
|
|
cmd += ["--parking", parking_preset]
|
|
if idle_alpha_preset:
|
|
cmd += ["--idle-alpha", idle_alpha_preset]
|
|
if idle_alpha_dedup:
|
|
cmd += ["--idle-alpha-dedup", idle_alpha_dedup]
|
|
|
|
print(f" Running {exp_name}...", flush=True)
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=str(REPO_ROOT),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
# Print last few lines of output
|
|
lines = result.stdout.splitlines() if result.stdout else []
|
|
for line in lines[-6:]:
|
|
print(f" {line}")
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"{exp_name} failed (returncode={result.returncode})")
|
|
|
|
# Find the most recently created run directory
|
|
run_dirs = sorted(run_output.glob("bt_*"), key=lambda p: p.stat().st_mtime)
|
|
if not run_dirs:
|
|
raise RuntimeError(f"No run directory found under {run_output}")
|
|
latest = run_dirs[-1]
|
|
|
|
# Read metrics
|
|
metrics_path = latest / "metrics" / "metrics_summary.json"
|
|
sleeve_path = latest / "metrics" / "sleeve_decomposition.json"
|
|
|
|
summary = json.loads(metrics_path.read_text()) if metrics_path.exists() else {}
|
|
sleeve = json.loads(sleeve_path.read_text()) if sleeve_path.exists() else {}
|
|
|
|
ia_injected = sleeve.get("ia_engines_injected", [])
|
|
ia_blocked = sleeve.get("ia_engines_blocked", [])
|
|
return {
|
|
"experiment": exp_name,
|
|
"run_dir": str(latest),
|
|
"trade_count": summary.get("trade_count", 0),
|
|
"total_return_pct": summary.get("total_return_pct"),
|
|
"sharpe_ratio": summary.get("sharpe_ratio"),
|
|
"max_drawdown_pct": summary.get("max_drawdown_pct"),
|
|
"composite_amplification": sleeve.get("composite_amplification"),
|
|
"avg_idle_fraction_pct": sleeve.get("avg_idle_fraction_pct"),
|
|
"core_contribution_pct": sleeve.get("core", {}).get("contribution_pct"),
|
|
"ia_contribution_pct": sleeve.get("idle_alpha", {}).get("contribution_pct"),
|
|
"parking_contribution_pct": sleeve.get("parking", {}).get("contribution_pct"),
|
|
"ia_engines_injected_count": len(ia_injected),
|
|
"ia_engines_blocked_count": len(ia_blocked),
|
|
"sleeve_file": str(sleeve_path) if sleeve_path.exists() else None,
|
|
}
|
|
|
|
|
|
def _fmt(v: object, decimals: int = 2) -> str:
|
|
if v is None:
|
|
return "N/A"
|
|
try:
|
|
return f"{float(v):.{decimals}f}" # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
return str(v)
|
|
|
|
|
|
def print_table(rows: list[dict[str, object]], sort_key: str) -> None:
|
|
sort_map = {
|
|
"cw": "total_return_pct",
|
|
"sharpe": "sharpe_ratio",
|
|
"dd": "max_drawdown_pct",
|
|
"amp": "composite_amplification",
|
|
"idle": "avg_idle_fraction_pct",
|
|
}
|
|
field = sort_map.get(sort_key, "total_return_pct")
|
|
reverse = sort_key != "dd" # lower DD is better
|
|
sorted_rows = sorted(
|
|
rows,
|
|
key=lambda r: (r.get(field) is not None, r.get(field) or 0.0),
|
|
reverse=reverse,
|
|
)
|
|
|
|
header = (
|
|
f"{'Experiment':<45} {'CW%':>8} {'Sharpe':>7} {'DD%':>6} "
|
|
f"{'Amp':>6} {'Idle%':>6} {'Core%':>6} {'IA%':>5} {'Park%':>6} {'IAInj':>6} {'IABlk':>6} {'N':>5}"
|
|
)
|
|
sep = "-" * len(header)
|
|
print(f"\n{sep}")
|
|
print(header)
|
|
print(sep)
|
|
for r in sorted_rows:
|
|
name = str(r["experiment"])[:44]
|
|
ia_inj = r.get("ia_engines_injected_count")
|
|
ia_blk = r.get("ia_engines_blocked_count")
|
|
print(
|
|
f"{name:<45} "
|
|
f"{_fmt(r['total_return_pct']):>8} "
|
|
f"{_fmt(r['sharpe_ratio']):>7} "
|
|
f"{_fmt(r['max_drawdown_pct']):>6} "
|
|
f"{_fmt(r['composite_amplification']):>6} "
|
|
f"{_fmt(r['avg_idle_fraction_pct']):>6} "
|
|
f"{_fmt(r['core_contribution_pct']):>6} "
|
|
f"{_fmt(r['ia_contribution_pct']):>5} "
|
|
f"{_fmt(r['parking_contribution_pct']):>6} "
|
|
f"{str(ia_inj) if ia_inj is not None else 'N/A':>6} "
|
|
f"{str(ia_blk) if ia_blk is not None else 'N/A':>6} "
|
|
f"{int(r['trade_count'] or 0):>5}"
|
|
)
|
|
print(sep)
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
output_root = Path(args.output_root)
|
|
output_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
parking = None if args.no_presets else args.parking
|
|
idle_alpha = None if args.no_presets else args.idle_alpha
|
|
preset_label = "no presets" if args.no_presets else f"parking={parking} ia={idle_alpha}"
|
|
print(f"Composite eval: {len(args.manifests)} experiment(s) [{preset_label}]")
|
|
|
|
rows: list[dict[str, object]] = []
|
|
errors: list[str] = []
|
|
for manifest in args.manifests:
|
|
resolved = _resolve_manifest_path(manifest)
|
|
try:
|
|
row = run_composite(
|
|
manifest=resolved,
|
|
parking_preset=parking,
|
|
idle_alpha_preset=idle_alpha,
|
|
split=args.split,
|
|
output_root=output_root,
|
|
snapshot_dir=args.snapshot_dir,
|
|
idle_alpha_dedup=args.idle_alpha_dedup,
|
|
)
|
|
rows.append(row)
|
|
except Exception as exc:
|
|
exp_name = Path(manifest).stem
|
|
print(f" ERROR {exp_name}: {exc}")
|
|
errors.append(exp_name)
|
|
|
|
if rows:
|
|
print_table(rows, sort_key=args.sort)
|
|
|
|
# Save summary JSON
|
|
summary_path = output_root / "composite_eval_summary.json"
|
|
summary_path.write_text(json.dumps(rows, indent=2) + "\n")
|
|
print(f"\nSaved summary to: {summary_path}")
|
|
|
|
if errors:
|
|
print(f"\nFailed experiments: {', '.join(errors)}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|