"""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))