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

299 lines
14 KiB
Python

"""SKH2_PATTERN_CONF — breakout CONFIRMATION filter family (DD-reduction wave).
GOAL of the wave: cut standalone maxDD < 30% (max over BTC&ETH) 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']}")