Files
PythagorasGoal/scripts/research/skyhook/runs/SKH2_ENS_PARAM.py
T
Adriano Dal Pastro de72e3ce1f 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>
2026-06-23 16:10:38 +00:00

286 lines
15 KiB
Python

"""SKH2_ENS_PARAM — within-sleeve PARAM ENSEMBLE for Skyhook DD reduction.
Family: equal-weight the DAILY returns of K diverse skyhook param sets (incl. the V2 winner),
varying ptn_n {25,45,90}, exits, sl/tp. Diversification across configs smooths equity and cuts
standalone DD without killing hold-out. We:
* build each config's per-asset 230m equity (sk.run_asset) -> daily returns,
* equal-weight average the configs' daily returns PER ASSET -> ensemble per-asset equity ->
standalone DD (max over BTC/ETH) and per-asset/year/full/hold Sharpe via the SAME _split logic,
* fee sweep: re-run each config at fee f, average daily, recompute Sharpe (fee_ok = Sharpe>0 @0.30% RT),
* causality: every member is a pure SkyhookParams variant -> sk.causality on each (must be ok),
* marginal: feed the 50/50 ensemble daily series to altlib.marginal_vs_tp01.
Standalone max_dd for the ensemble = max-DD of the COMBINED (averaged) per-asset equity curve.
All causal/leak-free: ensemble is a linear combo of leak-free member equities; no future data used.
"""
from __future__ import annotations
import sys
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook")
import numpy as np
import pandas as pd
import skyhooklib as sk
import altlib as al
from src.strategies.skyhook import SkyhookParams
HOLDOUT = sk.HOLDOUT
FEE = sk.FEE_RT
CERTIFIED = ("BTC", "ETH")
ANN = np.sqrt(365.25)
# The verified V2 winner (must be a member of every ensemble).
WINNER = 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)
def _sharpe(r: np.ndarray) -> float:
r = r[np.isfinite(r)]
return float(np.mean(r) / np.std(r) * ANN) if len(r) > 2 and np.std(r) > 0 else 0.0
def _dd_from_eq(eq: np.ndarray) -> float:
pk = np.maximum.accumulate(eq)
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
# ---------------------------------------------------------------------------
# Per-config DAILY equity-return series per asset, cached by (config-id, asset, fee).
# We use sk.run_asset to get the leak-free 230m equity, then resample to daily LAST and
# take pct_change -> daily returns. Aligning all members on the same daily index lets us
# equal-weight-average their daily returns (an equal-capital rebalanced ensemble).
# ---------------------------------------------------------------------------
_DAILY_CACHE: dict = {}
_NTR_CACHE: dict = {}
def _config_daily(p: SkyhookParams, asset: str, fee: float) -> pd.Series:
key = (id(p), asset, fee)
if key in _DAILY_CACHE:
return _DAILY_CACHE[key]
r = sk.run_asset(asset, p, fee)
s = pd.Series(r["_eq"], index=r["_idx"])
daily = s.resample("1D").last().ffill().pct_change().dropna()
_DAILY_CACHE[key] = daily
_NTR_CACHE[(id(p), asset)] = r["full"]["n_trades"]
return daily
def _ensemble_daily_asset(members, asset: str, fee: float) -> pd.Series:
"""Equal-weight average of members' daily returns for one asset (common dates)."""
cols = {f"m{i}": _config_daily(p, asset, fee) for i, p in enumerate(members)}
J = pd.concat(cols, axis=1, join="inner").fillna(0.0)
return J.mean(axis=1)
def study_ensemble(name: str, members) -> dict:
"""FULL+HOLD+fee-sweep+per-year on BOTH assets for the equal-weight param ensemble.
Standalone DD = max-DD of the averaged per-asset equity curve."""
per_asset = {}
fee_ok_all = True
for a in CERTIFIED:
ens = _ensemble_daily_asset(members, a, FEE)
eq = np.cumprod(1.0 + ens.values)
idx = ens.index
full_sh = _sharpe(ens.values)
full_dd = _dd_from_eq(eq)
full_ret = float(eq[-1] / eq[0] - 1) if len(eq) else 0.0
hmask = idx >= HOLDOUT
rh = ens.values[hmask]
eqh = np.cumprod(1.0 + rh) if rh.size else np.array([1.0])
hold_sh = _sharpe(rh)
hold_ret = float(eqh[-1] / eqh[0] - 1) if eqh.size else 0.0
hold_dd = _dd_from_eq(eqh)
# per-year Sharpe-equivalent return
yearly = {}
for y in sorted(set(idx.year)):
ry = ens.values[idx.year == y]
eqy = np.cumprod(1.0 + ry) if ry.size else np.array([1.0])
yearly[int(y)] = float(eqy[-1] - 1.0) if eqy.size else 0.0
# fee sweep
sweep = {}
for f in (0.0, 0.001, 0.002, 0.003):
ensf = _ensemble_daily_asset(members, a, f)
sweep[f"{f*100:.2f}%"] = round(_sharpe(ensf.values), 3)
fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0)
# n_trades = sum across members (the ensemble trades all of them)
ntr = sum(_NTR_CACHE.get((id(p), a), 0) for p in members)
per_asset[a] = dict(
full=dict(sharpe=round(full_sh, 3), ret=round(full_ret, 4), maxdd=round(full_dd, 4),
n_trades=int(ntr)),
hold=dict(sharpe=round(hold_sh, 3), ret=round(hold_ret, 4), maxdd=round(hold_dd, 4)),
yearly=yearly, fee_sweep=sweep)
mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset)
mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset)
mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset)
mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset)
grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \
("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL")
print(f"\n=== {name} -> {grade} (minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})")
for a in per_asset:
pa = per_asset[a]
yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items())
print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% DD={pa['full']['maxdd']*100:.0f}%"
f" n={pa['full']['n_trades']} | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}%")
print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items()))
print(f" yr: {yr}")
return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all,
per_asset=per_asset)
def ensemble_5050_daily(members, fee: float = FEE) -> pd.Series:
"""50/50 BTC+ETH ensemble daily series (same convention as altlib baseline) for marginal."""
sb = _ensemble_daily_asset(members, "BTC", fee)
se = _ensemble_daily_asset(members, "ETH", fee)
J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0)
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
def marginal_ensemble(members) -> dict:
return al.marginal_vs_tp01(ensemble_5050_daily(members))
def report(tag, members, names):
r = study_ensemble(tag, members)
caus = {}
for i, p in enumerate(members):
cb = sk.causality(p, "BTC")
ce = sk.causality(p, "ETH")
caus[names[i]] = (cb["ok"], ce["ok"])
caus_ok = all(b and e for b, e in caus.values())
mg = marginal_ensemble(members)
w25 = mg.get("blends", {}).get("w25", {})
w50 = mg.get("blends", {}).get("w50", {})
earns = (r["grade"] != "FAIL" and mg.get("marginal_verdict") == "ADDS"
and mg.get("robust_oos") is True and mg.get("is_hedge") is False)
beats = (earns and r["maxDD"] < 0.30 and (w25.get("uplift_hold") or -9) >= 0.55
and r["minHold"] >= 0.65)
print(f"\n----- MARGINAL [{tag}] -----")
print(f" members: {names}")
print(f" causality per member (BTC,ETH): {caus} -> all_ok={caus_ok}")
print(f" corr_full={mg.get('corr_full')} corr_hold={mg.get('corr_hold')} verdict={mg.get('marginal_verdict')}")
print(f" has_insample_edge={mg.get('has_insample_edge')} cand_insample_sharpe={mg.get('cand_insample_sharpe')}"
f" is_hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')} multicut_persistent={mg.get('multicut_persistent')}")
print(f" clean_year_uplift={mg.get('clean_year_uplift')} jackknife_min_uplift={mg.get('jackknife_min_uplift')}"
f" multicut_uplift={mg.get('multicut_uplift')}")
print(f" w25={w25}")
print(f" w50={w50}")
print(f" => earns_slot={earns} BEATS_WINNER={beats} (DD={r['maxDD']*100:.0f}% minHold={r['minHold']:+.2f} w25_up_hold={w25.get('uplift_hold')})")
return dict(study=r, marginal=mg, caus_ok=caus_ok, earns=earns, beats=beats, w25=w25, w50=w50)
if __name__ == "__main__":
# ---- Diverse member pool (all pure SkyhookParams variants, all causal) ----
# WINNER (ptn_n=45, sl2.5/tp7.0, exits 24/16, vola 35-95, vol_lo 0)
P_WIN = WINNER
# Faster pattern, tighter stop, shorter TP (different turnover/regime sensitivity)
P_FAST = SkyhookParams(ptn_n=25, sl_atr=2.0, tp_atr=5.0, uscitalong=18, uscitashort=12,
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
# Slow pattern, wider stop, longer TP (smoother, fewer trades)
P_SLOW = SkyhookParams(ptn_n=90, sl_atr=3.0, tp_atr=9.0, uscitalong=30, uscitashort=20,
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
# Mid pattern, percent exits (structurally different exit mode) + tighter vola band
P_PCT = SkyhookParams(ptn_n=45, exit_mode="pct", sl_pct=0.04, tp_pct=0.10,
uscitalong=24, uscitashort=16, vola_lo=30.0, vola_hi=90.0, vol_lo=0.0)
# Low-vol gate variant: add a vol floor + slightly different vola band (regime diversity)
P_GATE = SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=6.0, uscitalong=24, uscitashort=16,
vola_lo=40.0, vola_hi=95.0, vol_lo=40.0)
# DEFENSIVE members: tight stop cuts losers fast -> shallow per-trade DD (the DD-cutters).
P_TIGHT = SkyhookParams(ptn_n=45, sl_atr=1.5, tp_atr=4.5, uscitalong=18, uscitashort=12,
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
P_TIGHT2 = SkyhookParams(ptn_n=25, sl_atr=1.3, tp_atr=4.0, uscitalong=14, uscitashort=10,
vola_lo=35.0, vola_hi=95.0, vol_lo=0.0)
# Calm-regime gate (sit out high-vola tails) + tight stop -> lowest DD contributor
P_CALM = SkyhookParams(ptn_n=45, sl_atr=1.8, tp_atr=5.0, uscitalong=20, uscitashort=14,
vola_lo=20.0, vola_hi=70.0, vol_lo=0.0)
# Calm variants: narrower / different vola windows -> diverse DD timing among defenders.
P_CALM2 = SkyhookParams(ptn_n=90, sl_atr=1.8, tp_atr=5.5, uscitalong=24, uscitashort=16,
vola_lo=25.0, vola_hi=65.0, vol_lo=0.0)
P_CALM3 = SkyhookParams(ptn_n=45, sl_atr=2.0, tp_atr=6.0, uscitalong=24, uscitashort=16,
vola_lo=15.0, vola_hi=60.0, vol_lo=0.0)
# CALM4: strong hold-out defensive (wider TP like winner but calm band) — uplift booster
P_CALM4 = SkyhookParams(ptn_n=45, sl_atr=2.2, tp_atr=7.0, uscitalong=24, uscitashort=16,
vola_lo=15.0, vola_hi=65.0, vol_lo=0.0)
# CALM5: ptn_n=90 calm + wide TP (smooth, strong) for uplift+DD balance
P_CALM5 = SkyhookParams(ptn_n=90, sl_atr=2.0, tp_atr=7.0, uscitalong=28, uscitashort=18,
vola_lo=15.0, vola_hi=62.0, vol_lo=0.0)
POOL = {"WIN": P_WIN, "FAST": P_FAST, "SLOW": P_SLOW, "PCT": P_PCT, "GATE": P_GATE,
"TIGHT": P_TIGHT, "TIGHT2": P_TIGHT2, "CALM": P_CALM,
"CALM2": P_CALM2, "CALM3": P_CALM3, "CALM4": P_CALM4, "CALM5": P_CALM5}
# ---- First: standalone DD of each member (diagnostic) ----
print("\n===== STANDALONE per-member DD (max over BTC/ETH) =====")
for k, p in POOL.items():
dds, fhs, hhs = {}, {}, {}
for a in CERTIFIED:
r = sk.run_asset(a, p, FEE)
dds[a] = r["full"]["maxdd"]; fhs[a] = r["full"]["sharpe"]; hhs[a] = r["holdout"]["sharpe"]
print(f" {k:7s}: maxDD={max(dds.values())*100:.0f}% (BTC {dds['BTC']*100:.0f}/ETH {dds['ETH']*100:.0f})"
f" minFull={min(fhs.values()):+.2f} minHold={min(hhs.values()):+.2f}")
results = {}
# Smallest K first: K=3, then K=4, K=5 mixes (winner always included).
# Focus on DEFENSIVE-heavy mixes to drive standalone DD below 30%.
mixes = {
# maximize w25 uplift_hold (>=0.55) while keeping DD<30 -> strong-holdout calm members
"K3_WIN_CALM3_CALM4": ["WIN", "CALM3", "CALM4"],
"K3_WIN_CALM4_CALM5": ["WIN", "CALM4", "CALM5"],
"K3_WIN_CALM3_CALM5": ["WIN", "CALM3", "CALM5"],
"K3_WIN_PCT_CALM3": ["WIN", "PCT", "CALM3"], # PCT has hold 1.0 (uplift booster) but DD 43
"K4_WIN_CALM3_CALM4_CALM5":["WIN", "CALM3", "CALM4", "CALM5"],
"K3_WIN_CALM3_CALM2": ["WIN", "CALM3", "CALM2"], # prior: DD 24, uplift 0.508
"K3_WIN_CALM_CALM3": ["WIN", "CALM", "CALM3"], # prior: DD 23, uplift 0.493
"K4_WIN_GATE_CALM3_CALM4": ["WIN", "GATE", "CALM3", "CALM4"],
"K3_WIN_GATE_CALM3": ["WIN", "GATE", "CALM3"],
}
for tag, keys in mixes.items():
members = [POOL[k] for k in keys]
print(f"\n########## {tag} members={keys} ##########")
results[tag] = report(tag, members, keys)
# ---- pick best: prefer BEATS_WINNER, else best by (earns, low DD, hold) ----
def score(res):
r, w25 = res["study"], res["w25"]
uh = (w25.get("uplift_hold") or -9)
dd_ok = 1 if r["maxDD"] < 0.30 else 0 # goal #1 first
hold_ok = 1 if r["minHold"] >= 0.65 else 0 # goal #2
return (1 if res["beats"] else 0, 1 if res["earns"] else 0,
dd_ok, hold_ok, uh, -r["maxDD"])
best_tag = max(results, key=lambda t: score(results[t]))
bres = results[best_tag]
br, bw25, bw50, bmg = bres["study"], bres["w25"], bres["w50"], bres["marginal"]
print("\n\n##################### BEST ENSEMBLE #####################")
print(f"BEST = {best_tag} members={mixes[best_tag]}")
print(f"grade={br['grade']} minFull={br['minFull']:+.3f} minHold={br['minHold']:+.3f}"
f" max_dd={br['maxDD']:.4f} n_trades_min={br['minTr']} fee_ok(@0.30%)={br['fee_ok']}")
print(f"causality_ok={bres['caus_ok']}")
print(f"marginal: corr_full={bmg.get('corr_full')} verdict={bmg.get('marginal_verdict')}"
f" has_insample_edge={bmg.get('has_insample_edge')} is_hedge={bmg.get('is_hedge')}"
f" robust_oos={bmg.get('robust_oos')} multicut_persistent={bmg.get('multicut_persistent')}"
f" clean_year_uplift={bmg.get('clean_year_uplift')}")
print(f"blend w25 uplift_hold={bw25.get('uplift_hold')} uplift_full={bw25.get('uplift_full')}")
print(f"blend w50 full={bw50.get('full')} hold={bw50.get('hold')} dd={bw50.get('dd')}")
print(f"earns_slot={bres['earns']} BEATS_WINNER={bres['beats']}")
# Emit a machine-readable line so the agent can lift exact numbers.
import json
print("\nRESULT_JSON " + json.dumps({
"best_tag": best_tag, "members": mixes[best_tag],
"grade": br["grade"], "minFull": br["minFull"], "minHold": br["minHold"],
"max_dd": br["maxDD"], "n_trades_min": br["minTr"], "fee_ok": br["fee_ok"],
"causality_ok": bres["caus_ok"],
"corr_full": bmg.get("corr_full"), "verdict": bmg.get("marginal_verdict"),
"has_insample_edge": bmg.get("has_insample_edge"), "is_hedge": bmg.get("is_hedge"),
"robust_oos": bmg.get("robust_oos"), "multicut_persistent": bmg.get("multicut_persistent"),
"clean_year_uplift": bmg.get("clean_year_uplift"),
"w25_uplift_hold": bw25.get("uplift_hold"), "w50_full": bw50.get("full"),
"w50_hold": bw50.get("hold"), "w50_dd": bw50.get("dd"),
"earns_slot": bres["earns"], "beats_winner": bres["beats"],
"cand_insample_sharpe": bmg.get("cand_insample_sharpe"),
}, default=str))