Files
PythagorasGoal/scripts/research/skyhook/runs/SKH2_VOLTGT.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

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&ETH 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))