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>
323 lines
16 KiB
Python
323 lines
16 KiB
Python
"""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))
|