"""SKH2_KELTNER_PTN — KELTNER/ATR-channel breakout pattern (replaces Donchian). FAMILY: KELTNER_PTN. Goal of this wave = CUT standalone maxDD below 30% while keeping hold-out Sharpe high and earns_slot True. Idea: the V1/V2 Skyhook pattern is a Donchian breakout (close > rolling-high of n bars). Donchian highs/lows are driven by single wicks -> a fast spike can set a fresh extreme that the next close pokes through, firing a false breakout that mean-reverts -> drawdown. An ATR-CHANNEL (Keltner) breakout instead requires close to clear EMA(n) +/- k*ATR(n), a SMOOTHED reference that ignores isolated wicks. Steadier reference -> fewer wick-driven false entries -> potentially lower DD for similar exposure. We keep EVERYTHING ELSE identical to the verified V2 winner (regime Chande01 bands vola_lo=35/vola_hi=95/vol_lo=0, exits sl_atr=2.5/tp_atr=7.0/uscitalong=24/uscitashort=16) and ONLY swap the pattern from Donchian to Keltner. We do this by monkeypatching S.htf_features inside skyhooklib's namespace (same safe technique as SKH_R_EXPAND_study.py) so sk.study / sk.causality / sk.marginal run the EXACT honest machinery unchanged. CAUSALITY: EMA and ATR are causal ewm (use x[0..i] inclusive of the current, already-closed HTF bar); the channel for breakout-comparison is shift(1) (strictly prior bar's channel) so close[i] is compared against a band known BEFORE bar i closes -> leak-free. We verify with sk.causality (truncated-prefix guard) on BOTH assets. """ 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 ORIG_FEAT = S.htf_features FEE = sk.FEE_RT # --------------------------------------------------------------------------- # Keltner channel breakout on HTF (causal, shift-safe). # mid = EMA(close, n) # width = k * ATR(n) (ATR over the same n window, ewm) # upper = mid + width ; lower = mid - width # ptn_long = close[i] > upper[i-1] (clears the PRIOR bar's upper channel) # ptn_short = close[i] < lower[i-1] # The shift(1) on the channel makes the comparison strictly causal: the band the close must # clear is fully determined by bars <= i-1 (Donchian uses shift(1) on the rolling extreme for # the same reason). EMA/ATR ewm themselves use only past+current data. # --------------------------------------------------------------------------- def keltner_breakout(htf: pd.DataFrame, n: int, k: float, atr_win: int) -> tuple[np.ndarray, np.ndarray]: c = htf["close"].values.astype(float) mid = pd.Series(c).ewm(span=n, adjust=False, min_periods=n).mean().values a = S.atr(htf, atr_win) upper = mid + k * a lower = mid - k * a # compare current close vs the PRIOR bar's channel (shift 1) -> strictly causal upper_prev = pd.Series(upper).shift(1).values lower_prev = pd.Series(lower).shift(1).values ptn_long = np.where(np.isfinite(upper_prev), c > upper_prev, False) ptn_short = np.where(np.isfinite(lower_prev), c < lower_prev, False) return ptn_long.astype(bool), ptn_short.astype(bool) def make_keltner_features(n: int, k: float, kelt_atr_win: int): """Return an htf_features replacement: V1 Chande01 regime + Keltner pattern.""" def _feat(htf: pd.DataFrame, p: SkyhookParams) -> pd.DataFrame: buz_vola = S.chande01(S.atr(htf, p.atr_win), p.n_vola) buz_volume = S.chande01(htf["volume"].values, p.n_volume) ptn_long, ptn_short = keltner_breakout(htf, n, k, kelt_atr_win) regime_ok = ((buz_vola >= p.vola_lo) & (buz_vola <= p.vola_hi) & (buz_volume >= p.vol_lo) & (buz_volume <= p.vol_hi)) comp_long = regime_ok & ptn_long comp_short = regime_ok & ptn_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), }) return _feat def study_keltner(name, p, n, k, kelt_atr_win): """sk.study + causality + marginal with htf_features patched to Keltner.""" S.htf_features = make_keltner_features(n, k, kelt_atr_win) try: rep = sk.study(name, p) caus = sk.causality(p, "BTC") caus_eth = sk.causality(p, "ETH") marg = sk.marginal(p) finally: S.htf_features = ORIG_FEAT return rep, (caus, caus_eth), marg def earns_slot(rep, marg): grade_ok = rep["verdict"]["grade"] != "FAIL" return bool(grade_ok and marg.get("marginal_verdict") == "ADDS" and marg.get("robust_oos") is True and marg.get("is_hedge") is False) if __name__ == "__main__": # Winner exits/regime (the verified V2 winner) — only the pattern changes to Keltner. p = SkyhookParams(sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) # n in {13,20,34}, k in {1.0,1.5,2.0}; ATR window for the channel width = winner default 14. grid = [] for n in (13, 20, 34): for k in (1.0, 1.5, 2.0): grid.append((n, k)) KELT_ATR = 14 print("=== SKH2_KELTNER_PTN: ATR-channel (Keltner) breakout sweep (regime+exits = V2 winner) ===\n") print(f"grid n x k = {grid} kelt_atr_win={KELT_ATR}\n") # ---- Sweep (cheap pass: FULL/HOLD/DD/trades + fee survival via study) ---- rows = [] for (n, k) in grid: tag = f"KELT_n{n}_k{k}" rep, (cb, ce), marg = study_keltner(tag, p, n, k, KELT_ATR) v = rep["verdict"] # standalone DD = max over BTCÐ FULL maxdd dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"]) es = earns_slot(rep, marg) w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold") rows.append(dict(tag=tag, n=n, k=k, rep=rep, caus=(cb, ce), marg=marg, minFull=v["min_asset_full_sharpe"], minHold=v["min_asset_holdout_sharpe"], minTr=v["min_trades"], feeOK=v["fee_survives"], grade=v["grade"], dd=dd, es=es, w25=w25, caus_ok=(cb["ok"] and ce["ok"]))) beats = (es and dd < 0.30 and (w25 is not None and w25 >= 0.55) and v["min_asset_holdout_sharpe"] >= 0.65) print(f" [{tag}] grade={v['grade']} minFull={v['min_asset_full_sharpe']:+.2f}" f" minHold={v['min_asset_holdout_sharpe']:+.2f} minTr={v['min_trades']}" f" DD={dd*100:.0f}% feeOK={v['fee_survives']} caus={cb['ok'] and ce['ok']}" f" | verdict={marg.get('marginal_verdict')} corr={marg.get('corr_full')}" f" w25={w25} robust={marg.get('robust_oos')} hedge={marg.get('is_hedge')}" f" earns_slot={es} BEATS={beats}") # ======================================================================= # REFINEMENT PASS: the plain swap keeps DD>30% (too many entries / wick pokes). # DD is driven by (a) wide vola band letting in blow-off breakouts, (b) loose SL, # (c) shorts bleeding in a structural bull. Sweep regime-tightening + SL + long_only # around the best earns_slot region (n13/n20, k1.5-2.0) to push DD under 30%. # ======================================================================= print("\n--- REFINEMENT: tighten regime / SL / long_only to cut DD<30% ---") refine = [ # (n, k, sl_atr, tp_atr, vola_lo, vola_hi, vol_lo, long_only, tag) (13, 2.0, 2.0, 7.0, 35.0, 95.0, 0.0, False, "n13k2_sl2.0"), (13, 2.0, 2.5, 7.0, 45.0, 90.0, 0.0, False, "n13k2_vola45-90"), (13, 2.0, 2.5, 7.0, 35.0, 85.0, 0.0, False, "n13k2_volaHi85"), (13, 2.0, 2.5, 7.0, 35.0, 95.0, 40.0, False, "n13k2_volFloor40"), (13, 2.0, 2.5, 7.0, 35.0, 95.0, 0.0, True, "n13k2_longOnly"), (13, 2.0, 2.0, 7.0, 45.0, 90.0, 40.0, False, "n13k2_tight_all"), (20, 2.0, 2.0, 7.0, 35.0, 95.0, 0.0, False, "n20k2_sl2.0"), (20, 2.0, 2.5, 7.0, 45.0, 90.0, 40.0, False, "n20k2_tight_all"), (13, 2.5, 2.5, 7.0, 35.0, 95.0, 0.0, False, "n13k2.5"), (13, 2.5, 2.0, 7.0, 45.0, 90.0, 0.0, False, "n13k2.5_sl2_vola45-90"), # ---- pass 3: sl2.0 was the DD/hold winner; push SL tighter + lower TP (cut tail) ---- (13, 2.0, 1.5, 6.0, 35.0, 95.0, 0.0, False, "n13k2_sl1.5_tp6"), (13, 2.0, 1.5, 7.0, 35.0, 95.0, 40.0, False, "n13k2_sl1.5_volFloor40"), (13, 2.0, 2.0, 5.0, 35.0, 95.0, 0.0, False, "n13k2_sl2_tp5"), (13, 2.0, 2.0, 6.0, 35.0, 95.0, 40.0, False, "n13k2_sl2_tp6_volFloor40"), (13, 2.0, 1.5, 5.0, 35.0, 95.0, 0.0, False, "n13k2_sl1.5_tp5"), (13, 1.5, 2.0, 7.0, 35.0, 95.0, 0.0, False, "n13k1.5_sl2.0"), ] for (n, k, sl, tp, vlo, vhi, vol_lo, lo, tag) in refine: pr = SkyhookParams(sl_atr=sl, tp_atr=tp, uscitalong=24, uscitashort=16, vola_lo=vlo, vola_hi=vhi, vol_lo=vol_lo, long_only=lo) rep, (cb, ce), marg = study_keltner(tag, pr, n, k, KELT_ATR) v = rep["verdict"] dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"]) es = earns_slot(rep, marg) w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold") rows.append(dict(tag=tag, n=n, k=k, sl=sl, tp=tp, vlo=vlo, vhi=vhi, vol_lo=vol_lo, lo=lo, rep=rep, caus=(cb, ce), marg=marg, minFull=v["min_asset_full_sharpe"], minHold=v["min_asset_holdout_sharpe"], minTr=v["min_trades"], feeOK=v["fee_survives"], grade=v["grade"], dd=dd, es=es, w25=w25, caus_ok=(cb["ok"] and ce["ok"]))) b2 = (es and dd < 0.30 and (w25 is not None and w25 >= 0.55) and v["min_asset_holdout_sharpe"] >= 0.65) print(f" [{tag}] grade={v['grade']} minFull={v['min_asset_full_sharpe']:+.2f}" f" minHold={v['min_asset_holdout_sharpe']:+.2f} minTr={v['min_trades']}" f" DD={dd*100:.0f}% feeOK={v['fee_survives']} caus={cb['ok'] and ce['ok']}" f" | verdict={marg.get('marginal_verdict')} w25={w25} robust={marg.get('robust_oos')}" f" hedge={marg.get('is_hedge')} earns_slot={es} BEATS={b2}") # ---- Pick best: prefer DD<30% with earns_slot, then by (minHold, then w25) ---- def beats_winner(r): return bool(r["es"] and r["dd"] < 0.30 and (r["w25"] is not None and r["w25"] >= 0.55) and r["minHold"] >= 0.65) winners = [r for r in rows if beats_winner(r)] if winners: best = max(winners, key=lambda r: (r["minHold"], r["w25"] or -9)) pool = "BEATS-WINNER" else: # objective priority: DD<30 + earns_slot first; else best DD among earns_slot; # else best DD among fee-surviving non-FAIL; else lowest DD overall. cand1 = [r for r in rows if r["dd"] < 0.30 and r["es"]] # secondary-quality: earns_slot AND meets the two NON-DD beats gates (w25>=0.55, minHold>=0.65) candQ = [r for r in rows if r["es"] and (r["w25"] is not None and r["w25"] >= 0.55) and r["minHold"] >= 0.65] cand2 = [r for r in rows if r["es"]] cand3 = [r for r in rows if r["grade"] != "FAIL" and r["feeOK"]] if cand1: best = max(cand1, key=lambda r: (r["minHold"], r["w25"] or -9)); pool = "DD<30+earns_slot" elif candQ: # best DD among configs that already clear the other two beats gates best = min(candQ, key=lambda r: r["dd"]); pool = "earns_slot+w25>=.55+minHold>=.65 (DD>=30)" elif cand2: best = min(cand2, key=lambda r: r["dd"]); pool = "earns_slot (DD>=30)" elif cand3: best = min(cand3, key=lambda r: r["dd"]); pool = "fee-surviving non-FAIL" else: best = min(rows, key=lambda r: r["dd"]); pool = "lowest-DD overall" rep, marg = best["rep"], best["marg"] cb, ce = best["caus"] v = rep["verdict"] bl = marg.get("blends", {}) w25 = bl.get("w25", {}) w50 = bl.get("w50", {}) print("\n" + "=" * 78) print(f"BEST CONFIG ({pool}): {best['tag']} (n={best['n']}, k={best['k']}, kelt_atr_win={KELT_ATR})") print("=" * 78) print(sk.fmt(rep)) print(f"\nstandalone max_dd (max BTCÐ FULL) = {best['dd']:.4f} ({best['dd']*100:.1f}%)") print(f"causality BTC={cb} ETH={ce} -> ok={cb['ok'] and ce['ok']}") print(f"minFull={v['min_asset_full_sharpe']:+.3f} minHold={v['min_asset_holdout_sharpe']:+.3f}" f" minTrades={v['min_trades']} fee_survives_0.30%={v['fee_survives']}") print("\n--- MARGINAL vs TP01 ---") print(f" marginal_verdict = {marg.get('marginal_verdict')}") print(f" corr_full = {marg.get('corr_full')}") print(f" corr_hold = {marg.get('corr_hold')}") print(f" has_insample_edge = {marg.get('has_insample_edge')}") print(f" is_hedge = {marg.get('is_hedge')}") print(f" robust_oos = {marg.get('robust_oos')}") print(f" multicut_persistent= {marg.get('multicut_persistent')}") print(f" clean_year_uplift = {marg.get('clean_year_uplift')}") print(f" jackknife_min_uplift= {marg.get('jackknife_min_uplift')}") print(f" multicut_uplift = {marg.get('multicut_uplift')}") print(f" cand_insample_sharpe= {marg.get('cand_insample_sharpe')}") print(f" blend w25 uplift_hold = {w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')}") print(f" blend w50 = full={w50.get('full')} hold={w50.get('hold')}" f" uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}") es = best["es"] beats = beats_winner(best) print(f"\n earns_slot = {es}") print(f" BEATS_WINNER = {beats} " f"(need: earns_slot AND max_dd<0.30 AND w25_uplift_hold>=0.55 AND minHold>=0.65)") # ---- machine-readable final line for the orchestrator/agent to parse ---- import json out = dict( family="KELTNER_PTN", tag=best["tag"], best_config=dict(ptn_kind="keltner", n=best["n"], k=best["k"], kelt_atr_win=KELT_ATR, sl_atr=best.get("sl", 2.5), tp_atr=best.get("tp", 7.0), uscitalong=24, uscitashort=16, vola_lo=best.get("vlo", 35.0), vola_hi=best.get("vhi", 95.0), vol_lo=best.get("vol_lo", 0.0), long_only=best.get("lo", False)), min_full_sharpe=v["min_asset_full_sharpe"], min_hold_sharpe=v["min_asset_holdout_sharpe"], max_dd=best["dd"], n_trades_min=v["min_trades"], fee_survives_030=bool(v["fee_survives"]), causality_ok=bool(cb["ok"] and ce["ok"]), marginal_verdict=marg.get("marginal_verdict"), has_insample_edge=bool(marg.get("has_insample_edge")), is_hedge=bool(marg.get("is_hedge")), robust_oos=bool(marg.get("robust_oos")), multicut_persistent=bool(marg.get("multicut_persistent")), clean_year_uplift=marg.get("clean_year_uplift"), corr_full=marg.get("corr_full"), blend_w25_uplift_hold=w25.get("uplift_hold"), earns_slot=bool(es), beats_winner=bool(beats), ) print("\nFINAL_JSON=" + json.dumps(out, default=str))