de72e3ce1f
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>
286 lines
15 KiB
Python
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))
|