"""SKH2_VOLTGT — CAUSAL vol-target overlay on the V2 winner's daily return series. Family: vol-target overlay [VOLTGT]. Wave goal: cut standalone maxDD < 30% while keeping min-asset hold-out Sharpe >= ~0.70 and earns_slot True. Method: * Build the winner's daily return series per asset (from the honest intrabar equity). * Scale each day t by lev_t = min(cap, target_vol / rv_{t-1}) where rv_{t-1} is the trailing realized vol KNOWN AT t-1 (rolling window of past daily returns, .shift(1)). -> strictly causal: the scaler at day t uses returns up to and including day t-1 only. * scaled_ret_t = lev_t * ret_t. Rebuild scaled equity, measure DD per asset + combined. * Run altlib.marginal_vs_tp01 on the 50/50 scaled-combined daily series. We sweep target_vol in {15%,20%,25%}, cap in {1.5,2.0}, and a couple of vol windows. We prove causality of the scaler two ways: (1) construction (shift(1) -> rv known at t-1), (2) an explicit truncated-prefix recompute: lev_t computed on the full history must equal lev_t recomputed from only the returns up to t-1. The underlying winner entries are param-only -> their causality is sk.causality (0 mismatches). """ 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 ANN = np.sqrt(365.25) # ---- the verified V2 winner (baseline to beat) ---------------------------------------- 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 _sh(r: np.ndarray) -> float: r = np.asarray(r, float) r = r[np.isfinite(r)] if len(r) < 2 or np.std(r) == 0: return 0.0 return float(np.mean(r) / np.std(r) * ANN) def _maxdd_from_returns(r: pd.Series) -> float: eq = (1.0 + r.fillna(0.0)).cumprod().values pk = np.maximum.accumulate(eq) return float(np.max((pk - eq) / pk)) def _split_sharpe(r: pd.Series, mask) -> float: return _sh(r[mask].values) def _trailing_rv(daily: pd.Series, win: int, mode: str) -> pd.Series: """Annualized trailing realized vol KNOWN AT t-1 (shift(1) -> strictly past data).""" if mode == "ewma": # EWMA std of past returns, reacts faster to a vol spike than a flat window v = daily.ewm(span=win, min_periods=max(10, win // 2)).std().shift(1) else: v = daily.rolling(win, min_periods=max(10, win // 2)).std().shift(1) return v * ANN def vol_target_lev(daily: pd.Series, target_vol: float, cap: float, win: int, floor: float = 0.0, mode: str = "roll") -> pd.Series: """CAUSAL leverage series. rv_{t-1} = annualized trailing realized vol KNOWN at t-1. lev_t = clip(target/rv_{t-1}, floor, cap). cap<=1.0 => de-risk only (never lever up).""" rv = _trailing_rv(daily, win, mode) lev = (target_vol / rv).clip(lower=floor, upper=cap) # before we have enough history -> stay at min(1.0, cap) (no scaling, no look-ahead) lev = lev.where(rv.notna(), min(1.0, cap)).fillna(min(1.0, cap)) return lev def prove_scaler_causal(daily: pd.Series, target_vol: float, cap: float, win: int, mode: str = "roll", n_checks: int = 60) -> dict: """Truncated-prefix recompute: lev_t built on the FULL series must equal lev_t rebuilt from only returns up to t-1. Any leak (un-shifted vol) would break this.""" full = vol_target_lev(daily, target_vol, cap, win, mode=mode) n = len(daily) bad = 0 checked = 0 mp = max(10, win // 2) idxs = np.linspace(int(n * 0.5), n - 1, n_checks).astype(int) for t in sorted(set(idxs)): if t < 1: continue prefix = daily.iloc[:t] # returns up to and including day t-1 ONLY if mode == "ewma": rv_prev = prefix.ewm(span=win, min_periods=mp).std().iloc[-1] * ANN else: rv_prev = prefix.rolling(win, min_periods=mp).std().iloc[-1] * ANN if (not np.isfinite(rv_prev)) or rv_prev == 0: lev_t = min(1.0, cap) else: lev_t = float(np.clip(target_vol / rv_prev, 0.0, cap)) checked += 1 if abs(float(full.iloc[t]) - lev_t) > 1e-9: bad += 1 return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) def winner_daily(asset: str) -> pd.Series: """Winner's RAW daily return series for one asset (from honest intrabar equity).""" return sk.daily_returns(asset, WINNER, FEE) def run_overlay(target_vol: float, cap: float, win: int, floor: float = 0.0, mode: str = "roll") -> dict: """Apply the causal vol-target overlay per asset, combine 50/50, report DD + marginal.""" raw = {a: winner_daily(a) for a in ("BTC", "ETH")} scaled = {} lev_stats = {} scaler_caus = {} per_asset_dd_raw = {} per_asset_dd_scaled = {} per_asset_full_sh = {} per_asset_hold_sh = {} for a in ("BTC", "ETH"): d = raw[a] lev = vol_target_lev(d, target_vol, cap, win, floor, mode) s = (lev * d) scaled[a] = s lev_stats[a] = (round(float(lev.mean()), 3), round(float(lev.median()), 3), round(float((lev >= cap - 1e-9).mean()), 3)) scaler_caus[a] = prove_scaler_causal(d, target_vol, cap, win, mode) per_asset_dd_raw[a] = _maxdd_from_returns(d) per_asset_dd_scaled[a] = _maxdd_from_returns(s) hmask = s.index >= HOLDOUT per_asset_full_sh[a] = _sh(s.values) per_asset_hold_sh[a] = _split_sharpe(s, hmask) # combined 50/50 scaled series (same convention as sk.skyhook_daily_5050) J = pd.concat(scaled, axis=1, join="inner").fillna(0.0) comb = 0.5 * J["BTC"] + 0.5 * J["ETH"] comb_dd = _maxdd_from_returns(comb) comb_full_sh = _sh(comb.values) comb_hold_sh = _split_sharpe(comb, comb.index >= HOLDOUT) mg = al.marginal_vs_tp01(comb) max_dd = max(per_asset_dd_scaled.values()) # max over BTC & ETH (per-asset scaled DD) min_full = min(per_asset_full_sh.values()) min_hold = min(per_asset_hold_sh.values()) return dict(target_vol=target_vol, cap=cap, win=win, floor=floor, mode=mode, lev_stats=lev_stats, scaler_caus=scaler_caus, dd_raw=per_asset_dd_raw, dd_scaled=per_asset_dd_scaled, full_sh=per_asset_full_sh, hold_sh=per_asset_hold_sh, comb_dd=comb_dd, comb_full=comb_full_sh, comb_hold=comb_hold_sh, max_dd=max_dd, min_full=min_full, min_hold=min_hold, marginal=mg) def fee_survives_winner() -> bool: """The vol-target overlay does NOT change trade count/turnover materially (it scales an already-net daily series), so fee survival is the WINNER's fee survival. Report it.""" rep = sk.study("WINNER-fee", WINNER) ok = True for a, pa in rep["per_asset"].items(): ok = ok and (pa["fee_sweep"].get("0.30%RT", -9) > 0) return ok, rep def winner_min_trades() -> int: rep = sk.study("WINNER-tr", WINNER) return min(pa["full"]["n_trades"] for pa in rep["per_asset"].values()) if __name__ == "__main__": print("=" * 90) print("SKH2_VOLTGT — causal vol-target overlay on the V2 winner") print("Winner:", WINNER) print("=" * 90) # baseline reference: winner raw (no overlay) for context raw = {a: winner_daily(a) for a in ("BTC", "ETH")} Jr = pd.concat(raw, axis=1, join="inner").fillna(0.0) raw_comb = 0.5 * Jr["BTC"] + 0.5 * Jr["ETH"] print("\n--- WINNER RAW (no overlay), daily-return view ---") for a in ("BTC", "ETH"): print(f" {a}: dailyFullSh={_sh(raw[a].values):+.2f} " f"holdSh={_split_sharpe(raw[a], raw[a].index>=HOLDOUT):+.2f} " f"DD(daily)={_maxdd_from_returns(raw[a])*100:.0f}%") print(f" COMB: fullSh={_sh(raw_comb.values):+.2f} " f"holdSh={_split_sharpe(raw_comb, raw_comb.index>=HOLDOUT):+.2f} " f"DD={_maxdd_from_returns(raw_comb)*100:.0f}%") print(" NB daily-view Sharpe != intrabar headline Sharpe (winner minFull +0.83/minHold +0.81 " "are the intrabar numbers). The overlay's job is the DD; we judge marginal+DD on the daily series.") # KEY LESSON from v1 grid: cap>1.0 levers UP in low-vol regimes that precede crashes -> # per-asset BTC DD got WORSE (34%->43-55%). To CUT standalone per-asset DD<30% the cap must # be <=1.0 (DE-RISK ONLY: never amplify). We also test EWMA vol (reacts faster to spikes). GRID = [] for mode in ("roll", "ewma"): for tv in (0.15, 0.20, 0.25): for cap in (0.8, 1.0): # de-risk only for win in (20, 30): GRID.append((tv, cap, win, mode)) # FRONTIER scan: how much lever-up (cap) can we allow at tv=25 before BTC DD breaks 30%? # (rolling vol drove the highest uplift; we want max w25 uplift_hold subject to DD<0.30) for cap in (1.1, 1.2, 1.3): for win in (20, 30): GRID.append((0.25, cap, win, "roll")) GRID.append((0.25, cap, win, "ewma")) # plus a couple of cap=1.5 references to show the lever-up failure explicitly for tv in (0.20, 0.25): GRID.append((tv, 1.5, 20, "roll")) results = [] for tv, cap, win, mode in GRID: r = run_overlay(tv, cap, win, mode=mode) results.append(r) mg = r["marginal"] w25 = mg.get("blends", {}).get("w25", {}) print(f"\n[{mode} tv={tv:.0%} cap={cap} win={win}] " f"minFull(daily)={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} " f"max_dd={r['max_dd']*100:.0f}% (BTC {r['dd_scaled']['BTC']*100:.0f}%/" f"ETH {r['dd_scaled']['ETH']*100:.0f}% raw BTC {r['dd_raw']['BTC']*100:.0f}%/" f"ETH {r['dd_raw']['ETH']*100:.0f}%) combDD={r['comb_dd']*100:.0f}%") print(f" lev BTC mean/med/atcap={r['lev_stats']['BTC']} ETH={r['lev_stats']['ETH']} " f"scalerCausal BTC={r['scaler_caus']['BTC']['ok']} ETH={r['scaler_caus']['ETH']['ok']}") print(f" marginal: corr_full={mg.get('corr_full')} verdict={mg.get('marginal_verdict')} " f"insample_edge={mg.get('has_insample_edge')} hedge={mg.get('is_hedge')} " f"robust_oos={mg.get('robust_oos')} multicut={mg.get('multicut_persistent')} " f"cleanYr={mg.get('clean_year_uplift')} w25upHold={w25.get('uplift_hold')}") # ---- pick the best config: prioritize (1) max_dd<0.30, then (2) min_hold, then (3) w25 uplift_hold def beats(r): mg = r["marginal"] w25 = mg.get("blends", {}).get("w25", {}) es = (mg.get("marginal_verdict") == "ADDS" and mg.get("robust_oos") is True and mg.get("is_hedge") is False) return (es and r["max_dd"] < 0.30 and (w25.get("uplift_hold") or -9) >= 0.55 and r["min_hold"] >= 0.65) def _earns(r): mg = r["marginal"] return (mg.get("marginal_verdict") == "ADDS" and mg.get("robust_oos") is True and mg.get("is_hedge") is False) def score(r): mg = r["marginal"] w25 = mg.get("blends", {}).get("w25", {}) uh = w25.get("uplift_hold") or -9 # priority: (1) full BEATS, (2) DD<0.30 AND earns_slot AND hold>=0.65 (deployable DD-cut), # (3) max w25 uplift_hold, (4) max min_hold deployable = 1 if (r["max_dd"] < 0.30 and _earns(r) and r["min_hold"] >= 0.65) else 0 return (1 if beats(r) else 0, deployable, round(uh, 3), round(r["min_hold"], 3)) best = max(results, key=score) mg = best["marginal"] w25 = mg.get("blends", {}).get("w25", {}) w50 = mg.get("blends", {}).get("w50", {}) fee_ok, _ = fee_survives_winner() caus = sk.causality(WINNER, "BTC") caus_e = sk.causality(WINNER, "ETH") min_tr = winner_min_trades() scaler_ok = all(best["scaler_caus"][a]["ok"] for a in ("BTC", "ETH")) causality_ok = bool(caus["ok"] and caus_e["ok"] and scaler_ok) es = (mg.get("marginal_verdict") == "ADDS" and mg.get("robust_oos") is True and mg.get("is_hedge") is False) earns_slot = bool(es) # study grade for winner is PASS (it's the verified winner) beats_winner = bool(earns_slot and best["max_dd"] < 0.30 and (w25.get("uplift_hold") or -9) >= 0.55 and best["min_hold"] >= 0.65) print("\n" + "=" * 90) print("FINAL — BEST VOL-TARGET OVERLAY CONFIG") print("=" * 90) print(f" config: mode={best['mode']} target_vol={best['target_vol']:.0%} cap={best['cap']} win={best['win']}") print(f" minFull(daily) = {best['min_full']:+.3f}") print(f" minHold(daily) = {best['min_hold']:+.3f} (BTC {best['hold_sh']['BTC']:+.2f} / ETH {best['hold_sh']['ETH']:+.2f})") print(f" standalone max_dd (max BTCÐ scaled) = {best['max_dd']:.4f} " f"(BTC {best['dd_scaled']['BTC']:.3f} / ETH {best['dd_scaled']['ETH']:.3f})") print(f" RAW winner daily DD (no overlay) = BTC {best['dd_raw']['BTC']:.3f} / ETH {best['dd_raw']['ETH']:.3f}") print(f" combined scaled equity max_dd = {best['comb_dd']:.4f}") print(f" n_trades_min (winner) = {min_tr}") print(f" fee@0.30%RT survives (winner) = {fee_ok}") print(f" causality_ok (winner entries + scaler) = {causality_ok} " f"[winner BTC {caus} ETH {caus_e}; scaler BTC {best['scaler_caus']['BTC']} ETH {best['scaler_caus']['ETH']}]") print(f"\n MARGINAL vs TP01 (on scaled 50/50 daily):") print(f" corr_full = {mg.get('corr_full')}") print(f" corr_hold = {mg.get('corr_hold')}") print(f" verdict = {mg.get('marginal_verdict')}") print(f" has_insample_edge = {mg.get('has_insample_edge')} cand_insample_sharpe={mg.get('cand_insample_sharpe')}") print(f" is_hedge = {mg.get('is_hedge')}") print(f" robust_oos = {mg.get('robust_oos')}") print(f" multicut_persistent = {mg.get('multicut_persistent')} multicut={mg.get('multicut_uplift')}") print(f" clean_year_uplift = {mg.get('clean_year_uplift')} jackknife_min={mg.get('jackknife_min_uplift')}") print(f" blend w25: uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')} " f"hold={w25.get('hold')} full={w25.get('full')} dd={w25.get('dd')}") print(f" blend w50: full={w50.get('full')} hold={w50.get('hold')} " f"uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}") print(f"\n earns_slot = {earns_slot}") print(f" BEATS_WINNER = {beats_winner}") print("=" * 90) # machine-readable tail for the harness import json out = dict( family="voltgt", best_config=dict(strategy="winner+voltgt", mode=best["mode"], target_vol=best["target_vol"], cap=best["cap"], win=best["win"], winner=dict(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)), min_full=round(best["min_full"], 3), min_hold=round(best["min_hold"], 3), max_dd=round(best["max_dd"], 4), comb_dd=round(best["comb_dd"], 4), n_trades_min=min_tr, fee_ok=fee_ok, causality_ok=causality_ok, corr_full=mg.get("corr_full"), verdict=mg.get("marginal_verdict"), has_insample_edge=mg.get("has_insample_edge"), is_hedge=mg.get("is_hedge"), robust_oos=mg.get("robust_oos"), multicut=mg.get("multicut_persistent"), clean_year_uplift=mg.get("clean_year_uplift"), w25_uplift_hold=w25.get("uplift_hold"), earns_slot=earns_slot, beats_winner=beats_winner, ) print("JSON " + json.dumps(out, default=str))