#!/usr/bin/env python3 """Tier 3 profitability exit check (Plan 5 Task 4). Consumes the aggregated metrics JSON produced by `scripts/aggregate-multi-seed-metrics.py` (Plan 5 Task 1B.2). Pass criteria (per spec §2 Tier 3 — profitability): 1. `val_sharpe_annualised` mean > 1.0 on stable epochs. If only `val_sharpe` (per-bar) is in the aggregate, fall back to it as a proxy and document the difference in the PASS/FAIL message. 2. `val_win_rate` mean ≥ 0.52 — only enforced when the corresponding trade-count series indicates > 500 trades/bar over stable epochs. Below 500-trade gate, win-rate is too noisy to assess. 3. `val_profit_factor` mean ≥ 1.1 AND across-seed std < 0.3 on stable epochs (the std is the across-seed dispersion baked into each epoch entry by the aggregator). Exits 0 on PASS, 1 on FAIL. Each check prints a `PASS:` / `FAIL:` line. """ from __future__ import annotations import argparse import json import sys from statistics import mean def _stable(series, warmup_end): return [x for x in series if x["epoch"] >= warmup_end] def check_sharpe(agg, warmup_end): annualised = agg.get("val_sharpe_annualised") if annualised: stable = _stable(annualised, warmup_end) if not stable: return False, ( f"No stable epochs for val_sharpe_annualised past " f"warmup_end={warmup_end}" ) m = mean(x["mean"] for x in stable) if m <= 1.0: return False, f"val_sharpe_annualised mean = {m:.4f} <= 1.0" return True, f"val_sharpe_annualised mean = {m:.4f} > 1.0" # Fallback: per-bar val_sharpe — note proxy in message. proxy = agg.get("val_sharpe") if proxy: stable = _stable(proxy, warmup_end) if not stable: return False, ( f"No stable epochs for val_sharpe past warmup_end={warmup_end}" ) m = mean(x["mean"] for x in stable) # Per-bar sharpe is much smaller than annualised; use the same > 1.0 # threshold but flag the proxy substitution loud. if m <= 1.0: return False, ( f"val_sharpe (per-bar PROXY for annualised) mean = {m:.4f} " f"<= 1.0 — annualised metric missing from aggregate" ) return True, ( f"val_sharpe (per-bar PROXY for annualised) mean = {m:.4f} > 1.0" ) return False, ( "neither val_sharpe_annualised nor val_sharpe present in aggregate " "— required for Tier 3 profitability check" ) def check_win_rate(agg, warmup_end): series = agg.get("val_win_rate") if not series: return False, ( "val_win_rate missing from aggregate — required for Tier 3" ) stable = _stable(series, warmup_end) if not stable: return False, ( f"No stable epochs for val_win_rate past warmup_end={warmup_end}" ) # Trade-count gate. Use val_trades_per_bar mean × an assumed bar count # of 500; if explicit val_trade_count is present, prefer that. trade_count_series = agg.get("val_trade_count") if trade_count_series: tc_stable = _stable(trade_count_series, warmup_end) if tc_stable: tc = mean(x["mean"] for x in tc_stable) if tc <= 500: return True, ( f"val_win_rate skipped — trade count {tc:.0f} <= 500 " f"(too noisy)" ) else: # Approximate from per-bar trade rate × stable epoch span; if the # rate is below ~0.001 over even a long horizon the count is below # 500. Conservative — only skip if we can prove count <= 500. tpb_series = agg.get("val_trades_per_bar") if tpb_series: tpb_stable = _stable(tpb_series, warmup_end) if tpb_stable: tpb = mean(x["mean"] for x in tpb_stable) # Without knowing the eval window length, assume a # conservative 5e5 bars (a single day at 1Hz). If even # that yields <= 500 trades, skip. if tpb * 5e5 <= 500: return True, ( f"val_win_rate skipped — estimated trade count " f"{tpb * 5e5:.0f} <= 500 (too noisy)" ) m = mean(x["mean"] for x in stable) if m < 0.52: return False, f"val_win_rate mean = {m:.4f} < 0.52" return True, f"val_win_rate mean = {m:.4f} >= 0.52" def check_profit_factor(agg, warmup_end): series = agg.get("val_profit_factor") if not series: return False, ( "val_profit_factor missing from aggregate — required for Tier 3" ) stable = _stable(series, warmup_end) if not stable: return False, ( f"No stable epochs for val_profit_factor past warmup_end={warmup_end}" ) m = mean(x["mean"] for x in stable) s = mean(x["std"] for x in stable) if m < 1.1: return False, f"val_profit_factor mean = {m:.4f} < 1.1" if s >= 0.3: return False, ( f"val_profit_factor cross-seed std = {s:.4f} >= 0.3 (unstable)" ) return True, ( f"val_profit_factor mean = {m:.4f} >= 1.1, std = {s:.4f} < 0.3" ) def main(): ap = argparse.ArgumentParser( description="Tier 3 profitability exit check (Plan 5 Task 4).", ) ap.add_argument( "metrics_json", help="Aggregated metrics JSON (output of aggregate-multi-seed-metrics.py).", ) ap.add_argument( "--warmup-end", type=int, default=15, help="Epoch at which warmup ends (stable epochs are > this); default 15.", ) args = ap.parse_args() try: with open(args.metrics_json) as fh: data = json.load(fh) except (OSError, json.JSONDecodeError) as exc: print(f"FAIL: cannot read {args.metrics_json}: {exc}") sys.exit(1) agg = data.get("aggregates") if not isinstance(agg, dict): print("FAIL: missing or malformed `aggregates` block in metrics JSON") sys.exit(1) checks = [ check_sharpe(agg, args.warmup_end), check_win_rate(agg, args.warmup_end), check_profit_factor(agg, args.warmup_end), ] all_pass = all(ok for ok, _ in checks) for ok, msg in checks: print(("PASS" if ok else "FAIL") + ": " + msg) sys.exit(0 if all_pass else 1) if __name__ == "__main__": main()