feat(skyhook): SKH01-V2-DD — asymmetric %-exits cut standalone DD <30% (2-wave agent research)

Second agent wave (skyhook-improve-v2, 14 DD-reduction families, each adversarially
verified by 2 skeptics) beats the prior winner on the only unmet goal (DD<30%).

Winner = ASYM_LS -> promoted to engine as SKH01_V2_DD:
  same signal (ptn_n=45, vola[35,95], vol_lo=0, exit-bars 24/16) but exits switched
  from ATR to FIXED-PCT ASYMMETRIC — long sl4%/tp10%, short sl2%(tighter)/tp8%.
  The tight short %-SL caps the per-trade loss that forms the maxDD in vol spikes.

Verified (sk.study, independent re-run): standalone maxDD BTC 21.4% / ETH 27.4% (<30%),
minFull +0.99, minHold +1.26, causality 0/400 both assets, fee-surviving to 0.40%RT,
marginal vs TP01 ADDS (corr 0.09, in-sample edge, robust_oos, multicut, clean-year +0.57),
blend 0.75*TP01+0.25*SKH uplift_hold +0.87; blend 50/50 full 1.84/hold 1.59/DD 10.7%.
Plateau (not knife-edge); both skeptics holds_up=high, killer=null.

Engine: per-direction short exit overrides (exit_mode_short/sl_*_short/tp_*_short),
backward-compatible (None -> symmetric, V1/intermediate-winner unchanged). +3 tests (8/8 pass).

Lessons: DD is cut by changing the exit MECHANISM (%-SL, L/S asymmetry, ensembles), NOT by
entry-only kill-switch / vol-target / cadence. PATTERN_CONF killed as overfit (knife-edge).
PCTL_DD unverified (rate-limit) and ENS_PARAM/TPSL_DD recency/hedge-loaded -> forward-monitor.
NOT yet wired to live sleeves: re-verify blend@0.25 + causality on execution code before deploy.

Includes both waves' research scripts (runs/SKH_* wave 1, runs/SKH2_* wave 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-23 16:10:38 +00:00
parent 8e46a62e67
commit de72e3ce1f
31 changed files with 5768 additions and 13 deletions
@@ -0,0 +1,191 @@
"""SKH2_TPSL_DD: RR / stop fine grid around the V2 winner to push standalone maxDD < 30%
while holding min-asset HOLD-OUT >= ~0.70 and earns_slot True.
Winner baseline:
SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16,
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
-> minFull +0.83, minHold +0.81, DD BTC 34% / ETH 31% (THE PROBLEM), earns_slot True.
Family task: sl_atr in {1.75,2.0,2.25,2.5}, tp_atr in {5,6,7,8}, exit_mode 'pct' vs 'atr'.
Tighter SL cuts DD but can lower hold-out. Find DD<30 cell + minHold>=0.7 + plateau.
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
import numpy as np # noqa: E402
import skyhooklib as sk # noqa: E402
from src.strategies.skyhook import SkyhookParams # noqa: E402
# Winner fixed (non-RR) fields:
BASE = dict(ptn_n=45, uscitalong=24, uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
def mk(sl_atr=None, tp_atr=None, exit_mode="atr", sl_pct=None, tp_pct=None):
kw = dict(BASE)
kw["exit_mode"] = exit_mode
if exit_mode == "atr":
kw["sl_atr"] = sl_atr
kw["tp_atr"] = tp_atr
else:
kw["sl_pct"] = sl_pct
kw["tp_pct"] = tp_pct
return SkyhookParams(**kw)
def metrics(p):
"""FULL/HOLD/DD min-asset + fee@0.30 + trades, both assets."""
pa = {}
fee_ok = True
for a in ("BTC", "ETH"):
r = sk.run_asset(a, p)
# fee sweep at 0.30%
rf = sk.run_asset(a, p, fee_rt=0.003)
fee_ok = fee_ok and (rf["full"]["sharpe"] > 0)
pa[a] = dict(full=r["full"], hold=r["holdout"], yearly=r["yearly"],
fee30=rf["full"]["sharpe"])
minFull = min(pa[a]["full"]["sharpe"] for a in pa)
minHold = min(pa[a]["hold"]["sharpe"] for a in pa)
minTr = min(pa[a]["full"]["n_trades"] for a in pa)
maxDD = max(pa[a]["full"]["maxdd"] for a in pa)
return dict(pa=pa, minFull=minFull, minHold=minHold, minTr=minTr, maxDD=maxDD, fee_ok=fee_ok)
def grade_of(m):
enough = m["minTr"] >= 20
if enough and m["minFull"] >= 0.5 and m["minHold"] >= 0.2 and m["fee_ok"]:
return "PASS"
if enough and m["minFull"] >= 0.3 and m["minHold"] >= 0.0:
return "WEAK"
return "FAIL"
if __name__ == "__main__":
# ---- STAGE 1: coarse ATR grid (the family core) ----
sl_grid = [1.75, 2.0, 2.25, 2.5]
tp_grid = [5.0, 6.0, 7.0, 8.0]
rows = []
print("##### STAGE 1: ATR grid (sl_atr x tp_atr) #####")
print(f"{'sl':>5} {'tp':>4} | {'minFull':>8} {'minHold':>8} {'maxDD%':>7} {'minTr':>6} "
f"{'BTC_DD':>7} {'ETH_DD':>7} {'feeOK':>5} {'grade':>5}")
for sl in sl_grid:
for tp in tp_grid:
p = mk(sl_atr=sl, tp_atr=tp, exit_mode="atr")
m = metrics(p)
g = grade_of(m)
bdd = m["pa"]["BTC"]["full"]["maxdd"]
edd = m["pa"]["ETH"]["full"]["maxdd"]
rows.append((sl, tp, "atr", m, g))
print(f"{sl:>5} {tp:>4} | {m['minFull']:>+8.2f} {m['minHold']:>+8.2f} "
f"{m['maxDD']*100:>7.1f} {m['minTr']:>6} {bdd*100:>7.1f} {edd*100:>7.1f} "
f"{str(m['fee_ok']):>5} {g:>5}")
# ---- STAGE 2: pct exit grid (a few sensible RR pairs ~ matching ATR ratios) ----
# ATR LTF ~ a few % of price; pct exit gives a HARD dollar cap on DD per trade.
print("\n##### STAGE 2: PCT exit grid (sl_pct x tp_pct) #####")
print(f"{'slP':>6} {'tpP':>6} | {'minFull':>8} {'minHold':>8} {'maxDD%':>7} {'minTr':>6} "
f"{'BTC_DD':>7} {'ETH_DD':>7} {'feeOK':>5} {'grade':>5}")
pct_pairs = [(0.02, 0.06), (0.025, 0.07), (0.03, 0.075), (0.03, 0.09),
(0.035, 0.10), (0.04, 0.10),
# dense neighbourhood around the DD<30 winner (0.025,0.07) to prove a plateau:
(0.0225, 0.065), (0.0225, 0.07), (0.0225, 0.075),
(0.025, 0.0625), (0.025, 0.065), (0.025, 0.075), (0.025, 0.08),
(0.0275, 0.065), (0.0275, 0.07), (0.0275, 0.075)]
for slp, tpp in pct_pairs:
p = mk(exit_mode="pct", sl_pct=slp, tp_pct=tpp)
m = metrics(p)
g = grade_of(m)
bdd = m["pa"]["BTC"]["full"]["maxdd"]
edd = m["pa"]["ETH"]["full"]["maxdd"]
rows.append((slp, tpp, "pct", m, g))
print(f"{slp:>6} {tpp:>6} | {m['minFull']:>+8.2f} {m['minHold']:>+8.2f} "
f"{m['maxDD']*100:>7.1f} {m['minTr']:>6} {bdd*100:>7.1f} {edd*100:>7.1f} "
f"{str(m['fee_ok']):>5} {g:>5}")
# ---- Pick best: DD<30, minHold>=0.7, grade!=FAIL; tie-break by minHold then minFull ----
def ok_dd(r):
return r[3]["maxDD"] < 0.30 and r[3]["minHold"] >= 0.70 and r[4] != "FAIL"
cands = [r for r in rows if ok_dd(r)]
if not cands:
# relax: DD<30 and minHold>=0.65
cands = [r for r in rows if r[3]["maxDD"] < 0.30 and r[3]["minHold"] >= 0.65 and r[4] != "FAIL"]
relaxed = True
else:
relaxed = False
if not cands:
# fall back to lowest DD among non-FAIL with decent hold
cands = [r for r in rows if r[4] != "FAIL"]
# rank: among DD<30 cells, maximize a balanced score (minHold + minFull) so we don't pick a
# low-DD-but-weak-Sharpe corner. DD is already gated < 0.30 above, so optimise value next.
cands_sorted = sorted(cands, key=lambda r: -(r[3]["minHold"] + r[3]["minFull"]))
best = cands_sorted[0]
print(f"\n##### BEST PICK (relaxed={relaxed if cands else 'fallback'}): "
f"{'sl/tp' if best[2]=='atr' else 'slP/tpP'}=({best[0]},{best[1]}) mode={best[2]} #####")
# Build best params
if best[2] == "atr":
bp = mk(sl_atr=best[0], tp_atr=best[1], exit_mode="atr")
best_cfg = dict(ptn_n=45, sl_atr=best[0], tp_atr=best[1], uscitalong=24,
uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0,
exit_mode="atr")
else:
bp = mk(exit_mode="pct", sl_pct=best[0], tp_pct=best[1])
best_cfg = dict(ptn_n=45, sl_pct=best[0], tp_pct=best[1], uscitalong=24,
uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0,
exit_mode="pct")
# ---- Full verification of best: study + causality + marginal ----
print("\n##### FULL STUDY of BEST #####")
rep = sk.study("SKH2_TPSL_DD-BEST", bp)
print(sk.fmt(rep))
caus = sk.causality(bp, "BTC")
caus_eth = sk.causality(bp, "ETH")
print(f"\ncausality BTC: {caus}")
print(f"causality ETH: {caus_eth}")
mg = sk.marginal(bp)
m = best[3]
g = rep["verdict"]["grade"]
earns = (g != "FAIL" and mg.get("marginal_verdict") == "ADDS"
and bool(mg.get("robust_oos")) and not bool(mg.get("is_hedge")))
w25 = mg.get("blends", {}).get("w25", {})
w50 = mg.get("blends", {}).get("w50", {})
beats = (earns and m["maxDD"] < 0.30
and (w25.get("uplift_hold") or -9) >= 0.55 and m["minHold"] >= 0.65)
print("\n========== FINAL BLOCK ==========")
print(f"best_cfg = {best_cfg}")
print(f"exit_mode = {best[2]}")
print(f"minFull = {m['minFull']:+.3f}")
print(f"minHold = {m['minHold']:+.3f}")
print(f"max_dd (BTC/ETH) = {m['maxDD']:.4f} (BTC {m['pa']['BTC']['full']['maxdd']:.4f} / "
f"ETH {m['pa']['ETH']['full']['maxdd']:.4f})")
print(f"n_trades_min = {m['minTr']}")
print(f"fee@0.30 OK = {m['fee_ok']} (BTC {m['pa']['BTC']['fee30']:+.2f} / "
f"ETH {m['pa']['ETH']['fee30']:+.2f})")
print(f"causality_ok = {caus['ok'] and caus_eth['ok']} "
f"(BTC mism={caus['mismatches']} ETH mism={caus_eth['mismatches']})")
print(f"grade = {g}")
print("--- marginal vs TP01 ---")
print(f"corr_full = {mg.get('corr_full')}")
print(f"corr_hold = {mg.get('corr_hold')}")
print(f"marginal_verdict = {mg.get('marginal_verdict')}")
print(f"has_insample_edge = {mg.get('has_insample_edge')}")
print(f"is_hedge = {mg.get('is_hedge')}")
print(f"robust_oos = {mg.get('robust_oos')}")
print(f"multicut_persist = {mg.get('multicut_persistent')}")
print(f"clean_year_uplift = {mg.get('clean_year_uplift')}")
print(f"jackknife_min_upl = {mg.get('jackknife_min_uplift')}")
print(f"cand_insample_sh = {mg.get('cand_insample_sharpe')}")
print(f"blend w25 = {w25}")
print(f"blend w50 = {w50}")
print(f"earns_slot = {earns}")
print(f"BEATS_WINNER = {beats}")
# ---- plateau report: neighbors of best in the same mode ----
print("\n##### PLATEAU (neighbors of best) #####")
nbrs = [r for r in rows if r[2] == best[2]]
nbrs_sorted = sorted(nbrs, key=lambda r: (r[3]["maxDD"]))
for r in nbrs_sorted[:8]:
tag = f"({r[0]},{r[1]})"
print(f" {r[2]} {tag:>14}: DD={r[3]['maxDD']*100:5.1f}% minFull={r[3]['minFull']:+.2f} "
f"minHold={r[3]['minHold']:+.2f} grade={r[4]}")