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>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""SKH2_PATTERN_CONF — breakout CONFIRMATION filter family (DD-reduction wave).
|
||||
|
||||
GOAL of the wave: cut standalone maxDD < 30% (max over BTCÐ) while keeping
|
||||
min-asset HOLD-OUT Sharpe >= ~0.70 and earns_slot == True.
|
||||
|
||||
FAMILY = breakout confirmation. The main DD source is FALSE breakouts (whipsaws).
|
||||
We require CONFIRMATION before allowing the composer to fire, via a STRUCTURAL
|
||||
htf_features patch (causal, shift-safe). Confirmation modes (all use data <= close[i]):
|
||||
|
||||
persist2 : the breakout must PERSIST -> the *previous* HTF close also broke the
|
||||
donchian level that was active one bar earlier (2 consecutive breakouts).
|
||||
close_loc : the breakout close must sit in the upper/lower `loc_thr` of the HTF
|
||||
bar range (close near the high for a long, near the low for a short)
|
||||
-> rejects exhaustion wicks that close back inside the bar.
|
||||
roc_agree : HTF ROC (close/close[-roc_n]-1) sign must agree with the breakout dir.
|
||||
combos : AND-combinations of the above.
|
||||
|
||||
We monkeypatch S.htf_features INSIDE skyhooklib's namespace for the duration of each
|
||||
study (same safe pattern as SKH_R_EXPAND_study.py): only the feature/composer builder
|
||||
changes; pattern donchian, regime bands, entry/exit and ALL eval code are unchanged.
|
||||
|
||||
Baseline regime/exit params = the verified V2 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)
|
||||
|
||||
CAUSALITY: every confirmation feature is built from HTF columns and SHIFTED so that
|
||||
only data up to (and including) the breakout-bar close is used; donchian itself is
|
||||
shift(1) (strictly prior bars). We verify with sk.causality (truncated-prefix) which
|
||||
re-runs skyhook_entries on a prefix of BOTH frames -> our patched htf_features is
|
||||
exercised on the prefix and must match the full run.
|
||||
"""
|
||||
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
|
||||
from src.strategies import skyhook as S
|
||||
from src.strategies.skyhook import SkyhookParams, HTF_MIN
|
||||
from src.strategies.skyhook import atr as _atr, chande01 as _chande01
|
||||
|
||||
ORIG_FEAT = S.htf_features
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confirmed htf_features builder (STRUCTURAL). Same shape/columns as the engine's
|
||||
# htf_features, but the composer's pattern leg is gated by a CONFIRMATION mask.
|
||||
# ---------------------------------------------------------------------------
|
||||
def conf_htf_features(htf: pd.DataFrame, p: SkyhookParams, *,
|
||||
modes=("persist2",), loc_thr: float = 0.34, roc_n: int = 1):
|
||||
"""Causal regime+CONFIRMED-pattern features indexed by HTF close.
|
||||
|
||||
modes: subset of {"persist2","close_loc","roc_agree"} ANDed together as the
|
||||
confirmation requirement on top of the raw donchian breakout.
|
||||
"""
|
||||
h = htf["high"].values.astype(float)
|
||||
l = htf["low"].values.astype(float)
|
||||
c = htf["close"].values.astype(float)
|
||||
n = len(c)
|
||||
|
||||
# --- regime (unchanged) ---
|
||||
buz_vola = _chande01(_atr(htf, p.atr_win), p.n_vola)
|
||||
buz_volume = _chande01(htf["volume"].values, p.n_volume)
|
||||
regime_ok = ((buz_vola >= p.vola_lo) & (buz_vola <= p.vola_hi)
|
||||
& (buz_volume >= p.vol_lo) & (buz_volume <= p.vol_hi))
|
||||
|
||||
# --- raw donchian breakout (leak-free: close[i] vs max/min of n PRIOR bars) ---
|
||||
hi_n = pd.Series(h).rolling(p.ptn_n, min_periods=p.ptn_n).max().shift(1).values
|
||||
lo_n = pd.Series(l).rolling(p.ptn_n, min_periods=p.ptn_n).min().shift(1).values
|
||||
ptn_long = c > hi_n
|
||||
ptn_short = c < lo_n
|
||||
|
||||
# --- CONFIRMATION masks (all causal: built from data <= close[i]) ---
|
||||
conf_long = np.ones(n, dtype=bool)
|
||||
conf_short = np.ones(n, dtype=bool)
|
||||
|
||||
if "persist2" in modes:
|
||||
# previous bar's close also broke the donchian level active ONE bar earlier.
|
||||
# ptn_long shifted by 1 == "did the prior bar break out?" (its own causal level).
|
||||
prev_long = np.concatenate(([False], ptn_long[:-1]))
|
||||
prev_short = np.concatenate(([False], ptn_short[:-1]))
|
||||
conf_long &= prev_long
|
||||
conf_short &= prev_short
|
||||
|
||||
if "close_loc" in modes:
|
||||
rng = h - l
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
pos = np.where(rng > 0, (c - l) / rng, 0.5) # 0=at low, 1=at high; current bar only
|
||||
conf_long &= (pos >= (1.0 - loc_thr))
|
||||
conf_short &= (pos <= loc_thr)
|
||||
|
||||
if "roc_agree" in modes:
|
||||
cprev = pd.Series(c).shift(roc_n).values # close roc_n bars ago (causal)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
roc = np.where(np.isfinite(cprev) & (cprev != 0), c / cprev - 1.0, 0.0)
|
||||
conf_long &= (roc > 0.0)
|
||||
conf_short &= (roc < 0.0)
|
||||
|
||||
comp_long = regime_ok & ptn_long & conf_long
|
||||
comp_short = regime_ok & ptn_short & conf_short
|
||||
if p.long_only:
|
||||
comp_short = np.zeros_like(comp_short, dtype=bool)
|
||||
|
||||
close_ts = htf["timestamp"].astype("int64").values + HTF_MIN * 60 * 1000
|
||||
return pd.DataFrame({
|
||||
"close_ts": close_ts,
|
||||
"buz_vola": buz_vola, "buz_volume": buz_volume,
|
||||
"comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool),
|
||||
})
|
||||
|
||||
|
||||
def make_patched(**cfg):
|
||||
def _feat(htf, p):
|
||||
return conf_htf_features(htf, p, **cfg)
|
||||
return _feat
|
||||
|
||||
|
||||
def study_conf(name, p, cfg, do_marginal=True):
|
||||
"""Run sk.study/causality/marginal with htf_features patched to the confirmed builder."""
|
||||
S.htf_features = make_patched(**cfg)
|
||||
try:
|
||||
rep = sk.study(name, p)
|
||||
caus_btc = sk.causality(p, "BTC")
|
||||
caus_eth = sk.causality(p, "ETH")
|
||||
marg = sk.marginal(p) if do_marginal else None
|
||||
finally:
|
||||
S.htf_features = ORIG_FEAT
|
||||
return rep, (caus_btc, caus_eth), marg
|
||||
|
||||
|
||||
def _earns_slot(rep, marg):
|
||||
grade_ok = rep["verdict"]["grade"] != "FAIL"
|
||||
adds = marg.get("marginal_verdict") == "ADDS"
|
||||
robust = bool(marg.get("robust_oos"))
|
||||
hedge = bool(marg.get("is_hedge"))
|
||||
return bool(grade_ok and adds and robust and (not hedge))
|
||||
|
||||
|
||||
def _beats_winner(rep, marg, max_dd):
|
||||
es = _earns_slot(rep, marg)
|
||||
w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold")
|
||||
mh = rep["verdict"]["min_asset_holdout_sharpe"]
|
||||
return bool(es and max_dd < 0.30 and (w25 is not None and w25 >= 0.55) and mh >= 0.65)
|
||||
|
||||
|
||||
# Baseline regime/exit = verified V2 winner
|
||||
WIN = 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)
|
||||
|
||||
|
||||
def win_params():
|
||||
return SkyhookParams(**WIN)
|
||||
|
||||
|
||||
def win_params_ex(**over):
|
||||
d = dict(WIN); d.update(over); return SkyhookParams(**d)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = win_params()
|
||||
|
||||
# ---- PHASE 1: scan confirmation modes (quick, no marginal yet) ----
|
||||
# winner's exit = sl_atr 2.5 / tp_atr 7.0. close_loc+roc was the clear leader (DD 31.4%,
|
||||
# minHold +1.13). Now drive DD<30% via STRONGER close-location confirmation (tighter
|
||||
# loc_thr) and TIGHTER stops (sl_atr down), and an upper-vola cap to skip blow-offs.
|
||||
CL = ("close_loc",) # roc_agree is ~collinear with close_loc (no-op); drop it -> cleaner
|
||||
C = dict(modes=CL, loc_thr=0.40)
|
||||
scan = {
|
||||
"RAW (no conf, =winner)": (p, dict(modes=())),
|
||||
"cl 0.40 vH90": (win_params_ex(vola_hi=90.0), C),
|
||||
"cl 0.40 vH88": (win_params_ex(vola_hi=88.0), C),
|
||||
"cl 0.40 vH85": (win_params_ex(vola_hi=85.0), C),
|
||||
# volume floor: skip thin (low-volume-cycle) breakouts -> fewer false ETH whipsaws
|
||||
"cl 0.40 vH90 volLo20": (win_params_ex(vola_hi=90.0, vol_lo=20.0), C),
|
||||
"cl 0.40 vH90 volLo35": (win_params_ex(vola_hi=90.0, vol_lo=35.0), C),
|
||||
# raise vola_lo floor (skip dead-vol regimes) + cap top
|
||||
"cl 0.40 vL45 vH90": (win_params_ex(vola_lo=45.0, vola_hi=90.0), C),
|
||||
# tighten long hold to cut give-back on the trend reversals
|
||||
"cl 0.40 vH90 uL20": (win_params_ex(vola_hi=90.0, uscitalong=20), C),
|
||||
"cl 0.40 vH90 uL20 uS14": (win_params_ex(vola_hi=90.0, uscitalong=20, uscitashort=14), C),
|
||||
# tp tighter to bank wins sooner (less round-trip give-back) — keeps DD lower
|
||||
"cl 0.40 vH90 tp6": (win_params_ex(vola_hi=90.0, tp_atr=6.0), C),
|
||||
"cl 0.40 vH90 tp6 uL20": (win_params_ex(vola_hi=90.0, tp_atr=6.0, uscitalong=20), C),
|
||||
"cl 0.40 vH90 volLo20 uL20": (win_params_ex(vola_hi=90.0, vol_lo=20.0, uscitalong=20), C),
|
||||
}
|
||||
print("=" * 78)
|
||||
print("PHASE 1 SCAN — confirmation modes on the V2 winner (DD-focus)")
|
||||
print("=" * 78)
|
||||
rows = []
|
||||
for name, (pp, cfg) in scan.items():
|
||||
rep, caus, _ = study_conf(name, pp, cfg, do_marginal=False)
|
||||
v = rep["verdict"]
|
||||
mdd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
cb = caus[0]["ok"] and caus[1]["ok"]
|
||||
nmin = min(rep["per_asset"][a]["full"]["n_trades"] for a in rep["per_asset"])
|
||||
rows.append((name, (pp, cfg), v["grade"], v["min_asset_full_sharpe"],
|
||||
v["min_asset_holdout_sharpe"], mdd, nmin, v["fee_survives"], cb))
|
||||
print(f" {name:30s} grade={v['grade']:4s} minFull={v['min_asset_full_sharpe']:+.2f} "
|
||||
f"minHold={v['min_asset_holdout_sharpe']:+.2f} maxDD={mdd*100:4.0f}% "
|
||||
f"nMin={nmin:4d} feeOK={v['fee_survives']} caus={cb}")
|
||||
|
||||
# pick candidates: grade != FAIL, maxDD lowest, holdout decent. Take all with DD<32% & minHold>0.4.
|
||||
cands = [r for r in rows if r[2] != "FAIL" and r[7] and r[8]
|
||||
and r[5] < 0.33 and r[4] >= 0.40 and r[0] != "RAW (no conf, =winner)"]
|
||||
# sort: sub-30% DD first (the wave goal), then highest hold
|
||||
cands.sort(key=lambda r: (r[5] >= 0.30, r[5], -r[4]))
|
||||
print("\nPHASE 1 candidates (DD<35%, minHold>=0.40, feeOK, causal), best DD first:")
|
||||
for r in cands:
|
||||
print(f" {r[0]:24s} DD={r[5]*100:.0f}% minHold={r[4]:+.2f} minFull={r[3]:+.2f}")
|
||||
|
||||
# ---- PHASE 2: full marginal on the top few ----
|
||||
top = cands[:5] if cands else []
|
||||
if not top:
|
||||
# fall back to the lowest-DD non-FAIL configs regardless of hold threshold
|
||||
fb = [r for r in rows if r[2] != "FAIL" and r[7] and r[8]]
|
||||
fb.sort(key=lambda r: r[5])
|
||||
top = fb[:3]
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print("PHASE 2 — full marginal vs TP01 on top confirmation candidates")
|
||||
print("=" * 78)
|
||||
|
||||
best = None # (beats, earns, max_dd, minHold, name, cfg, rep, marg)
|
||||
for r in top:
|
||||
name, (pp, cfg) = r[0], r[1]
|
||||
rep, caus, marg = study_conf(name, pp, cfg, do_marginal=True)
|
||||
v = rep["verdict"]
|
||||
mdd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"])
|
||||
cb = caus[0]["ok"] and caus[1]["ok"]
|
||||
es = _earns_slot(rep, marg)
|
||||
bw = _beats_winner(rep, marg, mdd)
|
||||
w25 = marg.get("blends", {}).get("w25", {})
|
||||
w50 = marg.get("blends", {}).get("w50", {})
|
||||
print(f"\n----- {name} cfg={cfg} -----")
|
||||
print(sk.fmt(rep))
|
||||
print(f"causality: BTC={caus[0]} ETH={caus[1]} -> ok={cb}")
|
||||
print(f"marginal: verdict={marg.get('marginal_verdict')} corr_full={marg.get('corr_full')} "
|
||||
f"corr_hold={marg.get('corr_hold')}")
|
||||
print(f" has_insample_edge={marg.get('has_insample_edge')} "
|
||||
f"cand_insample_sharpe={marg.get('cand_insample_sharpe')} "
|
||||
f"is_hedge={marg.get('is_hedge')} robust_oos={marg.get('robust_oos')} "
|
||||
f"multicut_persistent={marg.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift={marg.get('clean_year_uplift')} "
|
||||
f"jackknife_min_uplift={marg.get('jackknife_min_uplift')} "
|
||||
f"multicut_uplift={marg.get('multicut_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')}")
|
||||
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" EARNS_SLOT={es} BEATS_WINNER={bw}")
|
||||
key = (bw, es, -mdd, v["min_asset_holdout_sharpe"])
|
||||
cur = dict(beats=bw, earns=es, max_dd=mdd, minHold=v["min_asset_holdout_sharpe"],
|
||||
minFull=v["min_asset_full_sharpe"], name=name, cfg=cfg, rep=rep, marg=marg,
|
||||
base=sk._params_dict(pp),
|
||||
caus=cb, nmin=min(rep["per_asset"][a]["full"]["n_trades"] for a in rep["per_asset"]),
|
||||
feeOK=v["fee_survives"], grade=v["grade"])
|
||||
if best is None or key > best[0]:
|
||||
best = (key, cur)
|
||||
|
||||
# ---- FINAL BLOCK ----
|
||||
print("\n" + "#" * 78)
|
||||
print("FINAL — BEST PATTERN_CONF CONFIG")
|
||||
print("#" * 78)
|
||||
if best is None:
|
||||
print("NO non-FAIL candidate found.")
|
||||
else:
|
||||
b = best[1]
|
||||
m = b["marg"]
|
||||
w25 = m.get("blends", {}).get("w25", {})
|
||||
w50 = m.get("blends", {}).get("w50", {})
|
||||
print(f"name : {b['name']}")
|
||||
print(f"cfg : {b['cfg']}")
|
||||
print(f"base params : {b['base']}")
|
||||
print(f"grade : {b['grade']}")
|
||||
print(f"minFull : {b['minFull']:+.3f}")
|
||||
print(f"minHold : {b['minHold']:+.3f}")
|
||||
print(f"max_dd : {b['max_dd']:.4f} ({b['max_dd']*100:.1f}%)")
|
||||
print(f"n_trades_min: {b['nmin']}")
|
||||
print(f"fee@0.30% : survives={b['feeOK']}")
|
||||
print(f"causality_ok: {b['caus']}")
|
||||
print(f"--- marginal dict ---")
|
||||
print(f" corr_full : {m.get('corr_full')}")
|
||||
print(f" corr_hold : {m.get('corr_hold')}")
|
||||
print(f" marginal_verdict : {m.get('marginal_verdict')}")
|
||||
print(f" has_insample_edge : {m.get('has_insample_edge')}")
|
||||
print(f" cand_insample_sh : {m.get('cand_insample_sharpe')}")
|
||||
print(f" is_hedge : {m.get('is_hedge')}")
|
||||
print(f" robust_oos : {m.get('robust_oos')}")
|
||||
print(f" multicut_persistent: {m.get('multicut_persistent')}")
|
||||
print(f" clean_year_uplift : {m.get('clean_year_uplift')}")
|
||||
print(f" blend w25 uplift_hold: {w25.get('uplift_hold')}")
|
||||
print(f" blend w25 uplift_full: {w25.get('uplift_full')}")
|
||||
print(f" blend w50 : full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}")
|
||||
print(f"EARNS_SLOT : {b['earns']}")
|
||||
print(f"BEATS_WINNER: {b['beats']}")
|
||||
Reference in New Issue
Block a user