From 64d98a070d886ba8103f4918f5cd7a59c226c9cf Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 23 Jun 2026 14:33:54 +0000 Subject: [PATCH 1/7] feat(skyhook): SKH01 dual-TF regime+breakout engine + honest eval harness Porting onesto del sistema ES Skyhook su BTC/ETH certificati: - src/strategies/skyhook.py: 690m(segnale)+230m(exec) da 5m; BuzVola/BuzVolume Chande 0-100 (ancore demo verificate); Donchian breakout HTF; regime gate; composer; entries asimmetrici (uscitalong/short + stop/profit ATR) per backtest_signals. - scripts/research/skyhook/skyhooklib.py: study (FULL/HOLD/fee-sweep/per-anno BTCÐ), causality guard (0 mismatch), marginal-vs-TP01. Baseline: BTC FULL Sh +0.91/+581%, ETH +0.64/+255%, fee-surviving, ma HOLD-OUT debole -> da migliorare. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/research/skyhook/skyhooklib.py | 217 +++++++++++++++++++++++ src/strategies/skyhook.py | 233 +++++++++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 scripts/research/skyhook/skyhooklib.py create mode 100644 src/strategies/skyhook.py diff --git a/scripts/research/skyhook/skyhooklib.py b/scripts/research/skyhook/skyhooklib.py new file mode 100644 index 0000000..a4bf3ff --- /dev/null +++ b/scripts/research/skyhook/skyhooklib.py @@ -0,0 +1,217 @@ +"""skyhooklib — SHARED HONEST EVAL for the Skyhook (SKH01) multi-agent improvement wave. + +Every agent imports THIS so results are comparable and leak-free: + * data builders: certified 5m BTC/ETH -> 230m (exec) + 690m (signal), cached. + * study(): FULL + HOLD-OUT (2025-01-01+) + fee sweep + per-year, on BOTH assets, via the + project's honest intrabar engine (backtest_signals: TP/SL/max_bars, non-overlap). + * causality(): truncated-prefix guard (a Skyhook entry on a prefix must match the full run). + * marginal(): does Skyhook ADD to the existing TP01 portfolio? (altlib.marginal_vs_tp01). + * verdict(): conservative PASS/WEAK/FAIL on min-asset FULL & HOLD-OUT + fee survival. + +Quick start (inside an agent script): + import sys; sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") + import skyhooklib as sk + from src.strategies.skyhook import SkyhookParams + rep = sk.study("MY-VARIANT", SkyhookParams(ptn_n=20, sl_atr=2.5)) + print(sk.fmt(rep)); print(sk.as_json(rep)) +""" +from __future__ import annotations + +import json +import sys +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +_ROOT = Path(__file__).resolve().parents[3] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) +sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt")) + +from src.backtest.harness import backtest_signals # noqa: E402 +from src.data.downloader import load_data # noqa: E402 +from src.strategies.skyhook import ( # noqa: E402 + SkyhookParams, build_frames, skyhook_entries, signal_counts) + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +FEE_RT = 0.001 # 0.10% round-trip (Deribit taker) +FEE_SWEEP = (0.0, 0.001, 0.002, 0.003) # round-trip fee grid +CERTIFIED = ("BTC", "ETH") + + +@lru_cache(maxsize=4) +def _frames(asset: str): + return build_frames(load_data(asset, "5m")) + + +def frames(asset: str): + """(ltf 230m, htf 690m) certificati e cached.""" + return _frames(asset.upper()) + + +def _split_metrics(eq: np.ndarray, idx: pd.DatetimeIndex, mask: np.ndarray) -> dict: + e = eq[mask] + if len(e) < 5: + return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e))) + r = np.diff(e) / e[:-1] + r = r[np.isfinite(r)] + ix = idx[mask] + dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400 + bpy = 86400 * 365.25 / dt + sharpe = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 + pk = np.maximum.accumulate(e) + dd = float(np.max((pk - e) / pk)) + return dict(sharpe=round(sharpe, 3), ret=round(float(e[-1] / e[0] - 1), 4), + maxdd=round(dd, 4), n=int(len(e))) + + +def run_asset(asset: str, p: SkyhookParams, fee_rt: float = FEE_RT) -> dict: + """Backtest Skyhook su un asset (230m exec). Ritorna FULL+HOLDOUT+per-anno+diagnostica.""" + ltf, htf = frames(asset) + entries = skyhook_entries(ltf, htf, p) + m = backtest_signals(ltf, entries, fee_rt=fee_rt, leverage=1.0, asset=asset, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), cagr=round(m.cagr, 4), maxdd=round(m.max_dd, 4), + ret=round(m.net_return, 4), n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = _split_metrics(eq, idx, hmask) + counts = signal_counts(ltf, htf, p) + return dict(asset=asset, full=full, holdout=hold, + yearly={int(y): round(v, 4) for y, v in m.yearly.items()}, + counts=counts, _eq=eq, _idx=idx) + + +def daily_returns(asset: str, p: SkyhookParams, fee_rt: float = FEE_RT) -> pd.Series: + """Rendimenti GIORNALIERI dell'equity Skyhook (per il lens marginal-vs-TP01). + NB approssimazione: l'equity di backtest_signals e' marcata a fine-trade (a gradini), + quindi i daily sono grezzi -> usalo SOLO per corr/uplift, non come headline Sharpe.""" + r = run_asset(asset, p, fee_rt) + s = pd.Series(r["_eq"], index=r["_idx"]) + return (s.resample("1D").last().ffill().pct_change().dropna()) + + +def skyhook_daily_5050(p: SkyhookParams, fee_rt: float = FEE_RT) -> pd.Series: + """Serie giornaliera 50/50 BTC+ETH (stessa convenzione di altlib.tp01_baseline_daily).""" + series = {a: daily_returns(a, p, fee_rt) for a in CERTIFIED} + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return 0.5 * J[CERTIFIED[0]] + 0.5 * J[CERTIFIED[1]] + + +def marginal(p: SkyhookParams, fee_rt: float = FEE_RT) -> dict: + """Skyhook MIGLIORA il portafoglio TP01 esistente? (altlib.marginal_vs_tp01).""" + import altlib as al + return al.marginal_vs_tp01(skyhook_daily_5050(p, fee_rt)) + + +# --------------------------------------------------------------------------- +# Causality guard (truncated-prefix): un ingresso emesso su un prefisso deve coincidere +# con lo stesso indice della run completa (nessuna feature guarda il futuro). +# --------------------------------------------------------------------------- +def causality(p: SkyhookParams, asset: str = "BTC", tail: int = 200) -> dict: + ltf, htf = frames(asset) + full = skyhook_entries(ltf, htf, p) + n = len(ltf) + bad = 0; checked = 0 + for frac in (0.80, 0.92): + cut = int(n * frac) + # taglia anche l'HTF alla stessa data di chiusura del prefisso LTF + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + sub = skyhook_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p) + for i in range(max(0, cut - tail), cut): + checked += 1 + a, b = full[i], sub[i] + if (a is None) != (b is None): + bad += 1 + elif a is not None and (a["dir"] != b["dir"] + or abs(a["sl"] - b["sl"]) > 1e-6 + or abs(a["tp"] - b["tp"]) > 1e-6): + bad += 1 + return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) + + +# --------------------------------------------------------------------------- +# Verdict + drivers +# --------------------------------------------------------------------------- +def _verdict(per_asset: dict, fee_survives: bool) -> dict: + min_full = min(per_asset[a]["full"]["sharpe"] for a in per_asset) + min_hold = min(per_asset[a]["holdout"]["sharpe"] for a in per_asset) + min_trades = min(per_asset[a]["full"]["n_trades"] for a in per_asset) + enough = min_trades >= 20 + pass_ = enough and min_full >= 0.5 and min_hold >= 0.2 and fee_survives + weak = enough and min_full >= 0.3 and min_hold >= 0.0 + grade = "PASS" if pass_ else ("WEAK" if weak else "FAIL") + return dict(grade=grade, min_asset_full_sharpe=round(min_full, 3), + min_asset_holdout_sharpe=round(min_hold, 3), + min_trades=int(min_trades), fee_survives=bool(fee_survives)) + + +def study(name: str, p: SkyhookParams | None = None, assets=CERTIFIED, + fee_sweep=FEE_SWEEP) -> dict: + """Run completo: FULL+HOLDOUT+fee-sweep+per-anno su BTCÐ + verdict conservativo.""" + p = p or SkyhookParams() + per_asset = {} + fee_ok_all = True + for a in assets: + r = run_asset(a, p, FEE_RT) + sweep = {} + for f in fee_sweep: + rf = run_asset(a, p, f) + sweep[f"{f*100:.2f}%RT"] = rf["full"]["sharpe"] + fee_ok = sweep.get("0.30%RT", -9) > 0 + fee_ok_all = fee_ok_all and fee_ok + per_asset[a] = dict(full=r["full"], holdout=r["holdout"], yearly=r["yearly"], + counts=r["counts"], fee_sweep=sweep) + return dict(name=name, params=_params_dict(p), per_asset=per_asset, + verdict=_verdict(per_asset, fee_ok_all)) + + +def _params_dict(p: SkyhookParams) -> dict: + return {k: getattr(p, k) for k in p.__dataclass_fields__} + + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- +def _clean(o): + if isinstance(o, dict): + return {k: _clean(v) for k, v in o.items() if not k.startswith("_")} + if isinstance(o, (list, tuple)): + return [_clean(x) for x in o] + if isinstance(o, (np.floating,)): + return round(float(o), 4) + if isinstance(o, (np.integer,)): + return int(o) + if isinstance(o, (np.bool_,)): + return bool(o) + return o + + +def as_json(rep: dict) -> str: + return json.dumps(_clean(rep), default=str) + + +def fmt(rep: dict) -> str: + v = rep["verdict"] + lines = [f"=== {rep['name']} -> {v['grade']} " + f"(minFull={v['min_asset_full_sharpe']:+.2f} minHold={v['min_asset_holdout_sharpe']:+.2f} " + f"minTrades={v['min_trades']} feeOK={v['fee_survives']})"] + for a, pa in rep["per_asset"].items(): + f, h, c = pa["full"], pa["holdout"], pa["counts"] + yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items()) + lines.append(f" {a}: FULL Sh={f['sharpe']:+.2f} ret={f['ret']*100:+.0f}% DD={f['maxdd']*100:.0f}% " + f"n={f['n_trades']} wr={f['win_rate']:.0f}% HOLD Sh={h['sharpe']:+.2f} ret={h['ret']*100:+.0f}% " + f"| entries={c['entries']} (L{c['comp_long']}/S{c['comp_short']})") + lines.append(f" fee sweep: " + " ".join(f"{k}={val:+.2f}" for k, val in pa["fee_sweep"].items())) + lines.append(f" per-anno: {yr}") + return "\n".join(lines) + + +if __name__ == "__main__": + print("--- SMOKE skyhooklib: baseline SkyhookParams() ---") + rep = study("SKH01-BASELINE", SkyhookParams()) + print(fmt(rep)) + print("\ncausality:", causality(SkyhookParams())) diff --git a/src/strategies/skyhook.py b/src/strategies/skyhook.py new file mode 100644 index 0000000..9137075 --- /dev/null +++ b/src/strategies/skyhook.py @@ -0,0 +1,233 @@ +"""SKYHOOK (SKH01) — dual-timeframe regime+breakout system, ported to BTC/ETH (2026-06-23). + +NON e' un trend-follower: entra SOLO quando coincidono (a) un REGIME di volatilita'/volume e +(b) un PATTERN di breakout/momentum. Porting onesto su BTC/ETH certificati (Deribit mainnet) +di un sistema ES (E-mini S&P) genetico a doppio timeframe. + +Architettura (dal brief): + * data2 = HTF 690 min (genera il SEGNALE: regime + pattern) + * data1 = LTF 230 min (ESEGUE: ingressi/uscite) NB 690 = 3 x 230 (HTF = 3x LTF) + Entrambi resampled dal feed 5m certificato con origin='epoch' -> i confini 690 sono un + SOTTOINSIEME dei confini 230, quindi una barra HTF chiude esattamente su una chiusura LTF. + +Pipeline per barra (evaluate_bar): barre -> indicatori -> fasce regime -> pattern -> composer + -> ingresso/uscita -> SkyhookDecision + 1. INDICATORI (sul HTF, tipo-Chande, normalizzati 0-100): + BuzVola = chande01(ATR) -> dove sei nel CICLO di volatilita' (flat -> 50) + BuzVolume= chande01(volume) -> dove sei nel CICLO di volume (rampa -> 100) + Ancore della demo del brief (trend lineare): ATR costante -> BuzVola=50 (neutro); + volume in rampa -> BuzVolume=100. Entrambe RICOSTRUITE esattamente da chande01. + 2. FASCE REGIME (Vola, Volume): trade ammesso solo se BuzVola in [vola_lo,vola_hi] E + BuzVolume in [vol_lo,vol_hi]. (Le "fasce 4/3/2 - 4/2/2" del sistema originale sono + ricostruite come bande-soglia tunabili: i magici interi non sono nel brief.) + 3. PATTERN (breakout su data2/HTF): Donchian leak-free a `ptn_n` barre (default 13, da 13/13/1). + ptn_long = close_htf rompe il massimo delle ptn_n barre PRECEDENTI + ptn_short = close_htf rompe il minimo delle ptn_n barre PRECEDENTI + 4. COMPOSER: contenitore_long = regime_ok AND ptn_long ; contenitore_short = regime_ok AND ptn_short + 5. INGRESSO (max 1 al giorno): se il composer e' attivo -> OPEN_LONG / OPEN_SHORT alla + chiusura LTF. (stop-and-reverse: non-overlap nell'engine -> il rovescio entra alla prima + barra utile dopo l'uscita se il segnale persiste.) + 6. USCITE: time-based ASIMMETRICO (uscitalong=24, uscitashort=18 barre LTF) + hard stop/profit. + Lo "stop 2000 / profit 5000" in $ del sistema ES e' tradotto in CRYPTO come multipli di ATR + LTF (scale-free): sl = k_sl*ATR, tp = k_tp*ATR (default 2.0/5.0 ~ il rapporto 40:100 pt ES), + con modalita' 'pct' alternativa (stop/profit in percentuale). + +CAUSALITA': ogni feature usa dati <= close della barra (HTF: donchian con shift(1), chande01 +rolling causale). Il merge HTF->LTF e' merge_asof BACKWARD sulla CHIUSURA HTF (<= chiusura LTF): +una barra HTF e' usata solo quando e' realmente chiusa. backtest_signals apre a close[i]. + +API: + from src.strategies.skyhook import SkyhookParams, build_frames, skyhook_entries + ltf, htf = build_frames(load_data("BTC","5m")) # resample 5m -> 230m + 690m + entries = skyhook_entries(ltf, htf, SkyhookParams()) # list[dict|None] len(ltf), per backtest_signals + from src.backtest.harness import backtest_signals + m = backtest_signals(ltf, entries, fee_rt=0.001); m.print_summary("SKH01 BTC") +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd + +# 690 = 3 x 230 ; entrambi multipli esatti di 5m (138 e 46 barre da 5m) +HTF_MIN = 690 # data2 — segnale +LTF_MIN = 230 # data1 — esecuzione + + +# --------------------------------------------------------------------------- +# Resample dal feed 5m certificato (origin='epoch' -> confini deterministici e allineati) +# --------------------------------------------------------------------------- +def resample_5m(df5: pd.DataFrame, minutes: int) -> pd.DataFrame: + """5m -> `minutes` barre (origin epoch). Schema con 'datetime' + 'timestamp' (open-labeled).""" + g = df5[["timestamp", "open", "high", "low", "close", "volume"]].copy() + g.index = pd.to_datetime(g["timestamp"], unit="ms", utc=True) + out = (g.resample(f"{minutes}min", label="left", closed="left", origin="epoch") + .agg({"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) + .dropna(subset=["open"])) + out["datetime"] = out.index + epoch = pd.Timestamp("1970-01-01", tz="UTC") + out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") + return out.reset_index(drop=True)[["timestamp", "open", "high", "low", "close", "volume", "datetime"]] + + +def build_frames(df5: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: + """Da un feed 5m certificato -> (ltf 230m exec, htf 690m signal).""" + return resample_5m(df5, LTF_MIN), resample_5m(df5, HTF_MIN) + + +# --------------------------------------------------------------------------- +# Indicatori causali +# --------------------------------------------------------------------------- +def atr(df: pd.DataFrame, win: int = 14) -> np.ndarray: + h, l, c = df["high"].values, df["low"].values, df["close"].values + pc = np.roll(c, 1); pc[0] = c[0] + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + return pd.Series(tr).ewm(alpha=1.0 / win, adjust=False).mean().values + + +def chande01(x: np.ndarray, n: int) -> np.ndarray: + """Chande Momentum Oscillator su `x`, normalizzato 0-100 (tipo-Chande). + CMO = (Su - Sd)/(Su + Sd) in [-1,1] sulle n variazioni; mappato (1+CMO)*50 -> [0,100]. + Serie piatta (variazioni nulle) -> 50 (neutro). Causale (rolling fino a i).""" + x = np.asarray(x, float) + d = np.diff(x, prepend=x[0]) + up = np.where(d > 0, d, 0.0) + dn = np.where(d < 0, -d, 0.0) + su = pd.Series(up).rolling(n, min_periods=n).sum().values + sd = pd.Series(dn).rolling(n, min_periods=n).sum().values + denom = su + sd + cmo = np.divide(su - sd, denom, out=np.zeros_like(denom), where=denom > 0) + out = 50.0 * (1.0 + cmo) + out[~np.isfinite(out)] = 50.0 + return out + + +def donchian_breakout(df: pd.DataFrame, n: int) -> tuple[np.ndarray, np.ndarray]: + """Breakout leak-free: close[i] rompe il max/min delle n barre STRETTAMENTE precedenti.""" + hi = pd.Series(df["high"].values).rolling(n, min_periods=n).max().shift(1).values + lo = pd.Series(df["low"].values).rolling(n, min_periods=n).min().shift(1).values + c = df["close"].values.astype(float) + return (c > hi), (c < lo) + + +# --------------------------------------------------------------------------- +# Parametri +# --------------------------------------------------------------------------- +@dataclass +class SkyhookParams: + # indicatori (HTF) + atr_win: int = 14 + n_vola: int = 13 # finestra Chande su ATR (da PtnL 13) + n_volume: int = 13 # finestra Chande su volume (da PtnL 13) + # fasce regime (bande-soglia su 0-100). Default = "regime di breakout": + # volume vivo (BuzVolume alto) + volatilita' presente ma non da blow-off. + vola_lo: float = 35.0 + vola_hi: float = 95.0 + vol_lo: float = 50.0 + vol_hi: float = 100.0 + # pattern (HTF) — Donchian breakout + ptn_n: int = 13 # da PtnL 13/13/1 + # composer / direzione + long_only: bool = False # Skyhook e' L/S di natura; True = solo long (stile crypto difensivo) + # ingresso + max_per_day: int = 1 + # uscite — time-based asimmetrico (barre LTF) + uscitalong: int = 24 + uscitashort: int = 18 + # uscite — hard stop/profit + exit_mode: str = "atr" # 'atr' = multipli di ATR LTF ; 'pct' = percentuale fissa + sl_atr: float = 2.0 + tp_atr: float = 5.0 + sl_pct: float = 0.03 + tp_pct: float = 0.075 + ltf_atr_win: int = 14 + + +# --------------------------------------------------------------------------- +# Feature HTF -> merge causale su LTF +# --------------------------------------------------------------------------- +def htf_features(htf: pd.DataFrame, p: SkyhookParams) -> pd.DataFrame: + """Calcola regime+pattern sull'HTF e li restituisce indicizzati per CHIUSURA HTF (timestamp + di chiusura = open + 690min). Cosi' il merge backward su LTF e' strettamente causale.""" + buz_vola = chande01(atr(htf, p.atr_win), p.n_vola) + buz_volume = chande01(htf["volume"].values, p.n_volume) + ptn_long, ptn_short = donchian_breakout(htf, p.ptn_n) + 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), + }) + + +def merge_htf_to_ltf(ltf: pd.DataFrame, feat: pd.DataFrame) -> pd.DataFrame: + """Attacca a ogni barra LTF l'ultima feature HTF la cui CHIUSURA <= chiusura LTF (causale).""" + left = ltf.copy() + left["close_ts"] = left["timestamp"].astype("int64").values + LTF_MIN * 60 * 1000 + m = pd.merge_asof(left.sort_values("close_ts"), + feat.sort_values("close_ts"), + on="close_ts", direction="backward") + return m.sort_index().reset_index(drop=True) + + +# --------------------------------------------------------------------------- +# Generatore di ingressi per backtest_signals ({'dir','tp','sl','max_bars'}) +# --------------------------------------------------------------------------- +def skyhook_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams | None = None) -> list: + """Lista di entry-dict (uno per barra LTF, None = niente segnale), pronta per + backtest_signals. Max `max_per_day` ingressi/giorno (prima barra qualificante del giorno). + sl/tp e max_bars asimmetrici per direzione. Tutto causale (decide a close[i]).""" + p = p or SkyhookParams() + feat = htf_features(htf, p) + m = merge_htf_to_ltf(ltf, feat) + + c = m["close"].values.astype(float) + a = atr(m, p.ltf_atr_win) + comp_long = np.nan_to_num(m["comp_long"].values).astype(bool) + comp_short = np.nan_to_num(m["comp_short"].values).astype(bool) + days = pd.to_datetime(m["datetime"]).dt.floor("D").values + + entries: list = [None] * len(m) + count_today: dict = {} + for i in range(len(m)): + if not np.isfinite(a[i]) or a[i] <= 0: + continue + day = days[i] + if count_today.get(day, 0) >= p.max_per_day: + continue + if comp_long[i]: + direction, mb = 1, p.uscitalong + elif comp_short[i]: + direction, mb = -1, p.uscitashort + else: + continue + if p.exit_mode == "atr": + sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i] + else: + sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i] + if direction == 1: + sl, tp = c[i] - sl_off, c[i] + tp_off + else: + sl, tp = c[i] + sl_off, c[i] - tp_off + entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)} + count_today[day] = count_today.get(day, 0) + 1 + return entries + + +def signal_counts(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams | None = None) -> dict: + """Diagnostica: quante barre passano regime/pattern/composer (prima del cap giornaliero).""" + p = p or SkyhookParams() + feat = htf_features(htf, p) + m = merge_htf_to_ltf(ltf, feat) + cl = np.nan_to_num(m["comp_long"].values).astype(bool) + cs = np.nan_to_num(m["comp_short"].values).astype(bool) + ent = skyhook_entries(ltf, htf, p) + return dict(ltf_bars=len(m), comp_long=int(cl.sum()), comp_short=int(cs.sum()), + entries=int(sum(e is not None for e in ent))) From 2d8faf3896b7bea26a4f676a7487b59764d6d2d7 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 23 Jun 2026 14:35:35 +0000 Subject: [PATCH 2/7] research(skyhook): inline lever-scout -> shorts essential, regime gate matters, ptn_n=55/vol_lo=40/wider-stops lift hold-out Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/research/skyhook/sweep.py | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 scripts/research/skyhook/sweep.py diff --git a/scripts/research/skyhook/sweep.py b/scripts/research/skyhook/sweep.py new file mode 100644 index 0000000..6d93281 --- /dev/null +++ b/scripts/research/skyhook/sweep.py @@ -0,0 +1,46 @@ +"""Fast inline lever-scout for Skyhook before the agent wave. One fee (0.10% RT), both assets, +min-asset FULL & HOLD-OUT Sharpe. Maps which knobs move the honest hold-out.""" +import sys +from dataclasses import replace + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + + +def quick(p: SkyhookParams) -> dict: + rs = {a: sk.run_asset(a, p, sk.FEE_RT) for a in sk.CERTIFIED} + mf = min(rs[a]["full"]["sharpe"] for a in rs) + mh = min(rs[a]["holdout"]["sharpe"] for a in rs) + mt = min(rs[a]["full"]["n_trades"] for a in rs) + avg_dd = sum(rs[a]["full"]["maxdd"] for a in rs) / 2 + return dict(minFull=mf, minHold=mh, minTr=mt, dd=round(avg_dd, 3), + btc=rs["BTC"]["full"]["sharpe"], eth=rs["ETH"]["full"]["sharpe"], + btcH=rs["BTC"]["holdout"]["sharpe"], ethH=rs["ETH"]["holdout"]["sharpe"]) + + +base = SkyhookParams() +GRID = { + "long_only": [dict(long_only=True), dict(long_only=False)], + "ptn_n": [dict(ptn_n=n) for n in (8, 13, 20, 34, 55)], + "RR(sl,tp)": [dict(sl_atr=s, tp_atr=t) for s, t in + ((1.5, 4.0), (2.0, 5.0), (2.5, 6.0), (3.0, 4.0), (2.0, 8.0), (3.0, 9.0))], + "exitbars": [dict(uscitalong=l, uscitashort=s) for l, s in + ((12, 9), (24, 18), (36, 24), (48, 36))], + "vola_band": [dict(vola_lo=lo, vola_hi=hi) for lo, hi in + ((0, 100), (35, 95), (50, 100), (50, 90), (20, 80))], + "vol_band": [dict(vol_lo=lo, vol_hi=hi) for lo, hi in + ((0, 100), (50, 100), (60, 100), (40, 100), (50, 80))], +} + +print(f"{'param':<14s} {'value':<28s} {'minF':>6s} {'minH':>6s} {'btc/eth F':>12s} {'btc/eth H':>12s} {'tr':>5s} {'dd':>5s}") +print(f" BASELINE: {quick(base)}") +print("-" * 100) +for fam, variants in GRID.items(): + for v in variants: + p = replace(base, **v) + q = quick(p) + tag = "PASS" if (q["minFull"] >= 0.5 and q["minHold"] >= 0.2) else "" + print(f"{fam:<14s} {str(v):<28s} {q['minFull']:>+6.2f} {q['minHold']:>+6.2f} " + f"{q['btc']:>+5.2f}/{q['eth']:>+5.2f} {q['btcH']:>+5.2f}/{q['ethH']:>+5.2f} " + f"{q['minTr']:>5d} {q['dd']*100:>4.0f}% {tag}") From c7c07f4c356eca3bbafb857cc6070d302def2873 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 23 Jun 2026 14:46:47 +0000 Subject: [PATCH 3/7] test(skyhook): demo anchors + dual-TF alignment + causality + V1 robustness (5 pass) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/research/skyhook/check_v1.py | 13 ++++ scripts/research/skyhook/grid.py | 26 +++++++ scripts/research/skyhook/runs/SKH_P_PTN.py | 82 +++++++++++++++++++ scripts/research/skyhook/runs/SKH_P_RR.py | 87 +++++++++++++++++++++ tests/test_skyhook.py | 91 ++++++++++++++++++++++ 5 files changed, 299 insertions(+) create mode 100644 scripts/research/skyhook/check_v1.py create mode 100644 scripts/research/skyhook/grid.py create mode 100644 scripts/research/skyhook/runs/SKH_P_PTN.py create mode 100644 scripts/research/skyhook/runs/SKH_P_RR.py create mode 100644 tests/test_skyhook.py diff --git a/scripts/research/skyhook/check_v1.py b/scripts/research/skyhook/check_v1.py new file mode 100644 index 0000000..36eb4fc --- /dev/null +++ b/scripts/research/skyhook/check_v1.py @@ -0,0 +1,13 @@ +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams +V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) +rep = sk.study("SKH01-V1", V1) +print(sk.fmt(rep)) +print("causality:", sk.causality(V1)) +print("\n--- marginal vs TP01 (does it ADD as a sleeve?) ---") +import altlib as al +print(al.fmt_marginal(dict(name="SKH01-V1", marginal=sk.marginal(V1), + abs_grade=rep["verdict"]["grade"], marginal_verdict=sk.marginal(V1).get("marginal_verdict"), + earns_slot=False))) diff --git a/scripts/research/skyhook/grid.py b/scripts/research/skyhook/grid.py new file mode 100644 index 0000000..ff48e3a --- /dev/null +++ b/scripts/research/skyhook/grid.py @@ -0,0 +1,26 @@ +"""Combined grid over the scout-winning levers -> rank by min-asset HOLD-OUT (gate minFull>=0.5).""" +import sys, itertools +from dataclasses import replace +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +base = SkyhookParams() +def quick(p): + rs = {a: sk.run_asset(a, p, sk.FEE_RT) for a in sk.CERTIFIED} + return (min(rs[a]["full"]["sharpe"] for a in rs), + min(rs[a]["holdout"]["sharpe"] for a in rs), + min(rs[a]["full"]["n_trades"] for a in rs), + round(sum(rs[a]["full"]["maxdd"] for a in rs)/2,3)) + +rows=[] +for ptn_n,(sl,tp),vol_lo,(vlo,vhi) in itertools.product( + (8,21,55), ((2.0,5.0),(2.5,6.0),(3.0,8.0)), (0.0,40.0,50.0), ((35.0,95.0),(25.0,95.0))): + p=replace(base, ptn_n=ptn_n, sl_atr=sl, tp_atr=tp, vol_lo=vol_lo, vola_lo=vlo, vola_hi=vhi) + mf,mh,mt,dd=quick(p) + rows.append((mh,mf,mt,dd,ptn_n,sl,tp,vol_lo,vlo,vhi)) +rows.sort(reverse=True) +print(f"{'minH':>6s}{'minF':>6s}{'tr':>5s}{'dd':>5s} ptn sl tp vlo vola") +for mh,mf,mt,dd,ptn_n,sl,tp,vol_lo,vlo,vhi in rows[:18]: + gate = "PASS" if (mf>=0.5 and mh>=0.2 and mt>=20) else "" + print(f"{mh:>+6.2f}{mf:>+6.2f}{mt:>5d}{dd*100:>4.0f}% {ptn_n:>3d} {sl:>3.1f} {tp:>4.1f} {vol_lo:>4.0f} [{vlo:.0f},{vhi:.0f}] {gate}") diff --git a/scripts/research/skyhook/runs/SKH_P_PTN.py b/scripts/research/skyhook/runs/SKH_P_PTN.py new file mode 100644 index 0000000..7c466e1 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_PTN.py @@ -0,0 +1,82 @@ +"""SKH_P_PTN (FAMILY=param) +On the SKH01-V1 base, sweep ptn_n in {34,45,55,70,89,110} x atr_win in {10,14,21}. +Slower Donchian breakouts may generalize better OOS. Maximize min-asset HOLD-OUT +subject to minFull>=0.5, fee survives 0.30%RT, >=20 trades BOTH assets, causality ok. +Note standalone DD. Always compare vs V1 (ptn_n=55, atr_win=14). +""" +import sys +import itertools +from dataclasses import replace + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +# SKH01-V1 reference base +V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + + +def quick(p: SkyhookParams) -> dict: + rs = {a: sk.run_asset(a, p, sk.FEE_RT) for a in sk.CERTIFIED} + mf = min(rs[a]["full"]["sharpe"] for a in rs) + mh = min(rs[a]["holdout"]["sharpe"] for a in rs) + mt = min(rs[a]["full"]["n_trades"] for a in rs) + avg_dd = sum(rs[a]["full"]["maxdd"] for a in rs) / 2 + return dict(minFull=mf, minHold=mh, minTr=mt, dd=round(avg_dd, 4), + btc=rs["BTC"]["full"]["sharpe"], eth=rs["ETH"]["full"]["sharpe"], + btcH=rs["BTC"]["holdout"]["sharpe"], ethH=rs["ETH"]["holdout"]["sharpe"], + btcDD=rs["BTC"]["full"]["maxdd"], ethDD=rs["ETH"]["full"]["maxdd"]) + + +PTN_GRID = (34, 45, 55, 70, 89, 110) +ATR_GRID = (10, 14, 21) + +print("=== SKH_P_PTN sweep: ptn_n x atr_win on SKH01-V1 base ===") +qv1 = quick(V1) +print(f"V1 (ptn55/atr14): minF={qv1['minFull']:+.2f} minH={qv1['minHold']:+.2f} " + f"btc/eth F={qv1['btc']:+.2f}/{qv1['eth']:+.2f} H={qv1['btcH']:+.2f}/{qv1['ethH']:+.2f} " + f"tr={qv1['minTr']} dd~{qv1['dd']*100:.0f}% (btc{qv1['btcDD']*100:.0f}/eth{qv1['ethDD']*100:.0f})") +print("-" * 108) +print(f"{'ptn':>4s}{'atr':>4s} {'minF':>6s}{'minH':>6s} {'btcF/ethF':>13s} {'btcH/ethH':>13s} " + f"{'tr':>4s} {'avgDD':>6s} {'btcDD/ethDD':>12s} gate") + +rows = [] +for ptn_n, atr_win in itertools.product(PTN_GRID, ATR_GRID): + p = replace(V1, ptn_n=ptn_n, atr_win=atr_win) + q = quick(p) + # gate per task: minFull>=0.5 AND minHold>=0.2 AND minTr>=20 + gate = (q["minFull"] >= 0.5 and q["minHold"] >= 0.2 and q["minTr"] >= 20) + rows.append((q["minHold"], q["minFull"], q["minTr"], q["dd"], ptn_n, atr_win, q, gate)) + tag = "PASS" if gate else "" + print(f"{ptn_n:>4d}{atr_win:>4d} {q['minFull']:>+6.2f}{q['minHold']:>+6.2f} " + f"{q['btc']:>+5.2f}/{q['eth']:>+5.2f} {q['btcH']:>+5.2f}/{q['ethH']:>+5.2f} " + f"{q['minTr']:>4d} {q['dd']*100:>5.0f}% {q['btcDD']*100:>4.0f}/{q['ethDD']*100:>4.0f}% {tag}") + +# winner = max min-asset HOLD-OUT among gate-passers (minFull>=0.5, minTr>=20); fallback best minHold +passers = [r for r in rows if r[7]] +pool = passers if passers else [r for r in rows if r[1] >= 0.5 and r[2] >= 20] +if not pool: + pool = rows +# rank by minHold, tiebreak lower avgDD then higher minFull +pool.sort(key=lambda r: (r[0], -r[3], r[1]), reverse=True) +best = pool[0] +b_ptn, b_atr = best[4], best[5] +print("-" * 108) +print(f"WINNER: ptn_n={b_ptn} atr_win={b_atr} minH={best[0]:+.2f} minF={best[1]:+.2f} " + f"tr={best[2]} avgDD={best[3]*100:.0f}%") + +# Full study + causality + marginal on winner (and re-confirm V1 alongside) +WIN = replace(V1, ptn_n=b_ptn, atr_win=b_atr) +print("\n=== STUDY winner ===") +rep = sk.study(f"SKH_P_PTN ptn{b_ptn}/atr{b_atr}", WIN) +print(sk.fmt(rep)) +caus = sk.causality(WIN, "BTC") +caus_eth = sk.causality(WIN, "ETH") +print(f"causality BTC: {caus} ETH: {caus_eth}") +mg = sk.marginal(WIN) +print(f"marginal: corr_full={mg.get('corr_full')} " + f"blend_w25_uplift_hold={mg.get('blends', {}).get('w25', {}).get('uplift_hold')} " + f"verdict={mg.get('marginal_verdict')} has_insample_edge={mg.get('has_insample_edge')} " + f"is_hedge={mg.get('is_hedge')}") +print("\nJSON_STUDY:", sk.as_json(rep)) +print("MARGINAL:", mg) diff --git a/scripts/research/skyhook/runs/SKH_P_RR.py b/scripts/research/skyhook/runs/SKH_P_RR.py new file mode 100644 index 0000000..b7d44e1 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_RR.py @@ -0,0 +1,87 @@ +"""SKH_P_RR — fine-sweep reward:risk on the ptn_n=55 V1 base. + +V1 base: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + -> minFull +0.69, HOLD +0.64 (BTC 0.64 / ETH 0.64), DD ~40-49% (HIGH). + +Sweep: sl_atr in {2.0,2.25,2.5,2.75,3.0,3.5} x tp_atr in {5,6,7,8,9,10}. +Objective: maximize min-asset HOLD-OUT subject to minFull>=0.5, cut DD. Report best + plateau. +""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +BASE = dict(ptn_n=55, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + +SL_GRID = [2.0, 2.25, 2.5, 2.75, 3.0, 3.5] +TP_GRID = [5.0, 6.0, 7.0, 8.0, 9.0, 10.0] + +def cell(sl, tp): + p = SkyhookParams(sl_atr=sl, tp_atr=tp, **BASE) + out = {} + for a in ("BTC", "ETH"): + r = sk.run_asset(a, p, fee_rt=sk.FEE_RT) + out[a] = r + min_full = min(out[a]["full"]["sharpe"] for a in out) + min_hold = min(out[a]["holdout"]["sharpe"] for a in out) + min_tr = min(out[a]["full"]["n_trades"] for a in out) + max_dd = max(out[a]["full"]["maxdd"] for a in out) + return dict(sl=sl, tp=tp, min_full=min_full, min_hold=min_hold, + min_tr=min_tr, max_dd=max_dd, + btc_full=out["BTC"]["full"]["sharpe"], eth_full=out["ETH"]["full"]["sharpe"], + btc_hold=out["BTC"]["holdout"]["sharpe"], eth_hold=out["ETH"]["holdout"]["sharpe"], + btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"]) + +print("=== SKH_P_RR sweep (ptn_n=55 base) — fee=0.10%RT ===") +print(f"{'sl':>5} {'tp':>5} | {'minFull':>7} {'minHold':>7} {'minTr':>5} {'maxDD':>6} | " + f"{'btcF':>5} {'ethF':>5} {'btcH':>5} {'ethH':>5} {'btcDD':>5} {'ethDD':>5}") +results = [] +for sl in SL_GRID: + for tp in TP_GRID: + if tp <= sl: # tp must exceed sl for a sensible R:R; skip degenerate + continue + c = cell(sl, tp) + results.append(c) + flag = "" + if c["min_full"] >= 0.5 and c["min_tr"] >= 20: + flag = " *" # eligible + print(f"{sl:>5} {tp:>5} | {c['min_full']:>+7.2f} {c['min_hold']:>+7.2f} " + f"{c['min_tr']:>5} {c['max_dd']*100:>5.0f}% | " + f"{c['btc_full']:>+5.2f} {c['eth_full']:>+5.2f} " + f"{c['btc_hold']:>+5.2f} {c['eth_hold']:>+5.2f} " + f"{c['btc_dd']*100:>4.0f}% {c['eth_dd']*100:>4.0f}%{flag}") + +# Eligible = minFull>=0.5, minTrades>=20. Rank by min_hold, tie-break lower maxDD. +elig = [c for c in results if c["min_full"] >= 0.5 and c["min_tr"] >= 20] +print(f"\nEligible cells (minFull>=0.5, minTr>=20): {len(elig)}") +if elig: + elig_sorted = sorted(elig, key=lambda c: (-round(c["min_hold"], 3), c["max_dd"])) + print("Top by minHold (tie-break lower maxDD):") + for c in elig_sorted[:6]: + print(f" sl={c['sl']} tp={c['tp']}: minHold={c['min_hold']:+.2f} " + f"minFull={c['min_full']:+.2f} maxDD={c['max_dd']*100:.0f}% minTr={c['min_tr']}") + best = elig_sorted[0] + # DD-cutting candidate: best minHold among cells with maxDD < V1-ish (lower DD priority) + dd_cands = sorted(elig, key=lambda c: (c["max_dd"], -round(c["min_hold"], 3))) + print("\nTop by lowest maxDD (DD-cut objective):") + for c in dd_cands[:6]: + print(f" sl={c['sl']} tp={c['tp']}: maxDD={c['max_dd']*100:.0f}% " + f"minHold={c['min_hold']:+.2f} minFull={c['min_full']:+.2f} minTr={c['min_tr']}") + + print("\n=== STUDY on best-by-minHold ===") + pbest = SkyhookParams(sl_atr=best["sl"], tp_atr=best["tp"], **BASE) + rep = sk.study(f"P_RR_sl{best['sl']}_tp{best['tp']}", pbest) + print(sk.fmt(rep)) + print("causality:", sk.causality(pbest)) + print("marginal:", {k: v for k, v in sk.marginal(pbest).items() + if k in ("corr_full","marginal_verdict","has_insample_edge","is_hedge","robust_oos")}) + try: + mg = sk.marginal(pbest) + print("marginal-full-keys:", list(mg.keys())) + print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold")) + except Exception as e: + print("marginal err:", e) + print("\nAS_JSON_STUDY:", sk.as_json(rep)) +else: + print("No eligible cell — V1 base may already be at the frontier.") diff --git a/tests/test_skyhook.py b/tests/test_skyhook.py new file mode 100644 index 0000000..e3efc11 --- /dev/null +++ b/tests/test_skyhook.py @@ -0,0 +1,91 @@ +"""Test della strategia SKH01 (Skyhook) — dual-timeframe regime+breakout su BTC/ETH. + +Coprono: fedelta' al brief (ancore demo BuzVola/BuzVolume), allineamento dual-TF, assenza di +look-ahead (causalita'), e robustezza onesta del config V1 su entrambi gli asset. +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "skyhook")) + +from src.data.downloader import load_data +from src.strategies.skyhook import ( + HTF_MIN, LTF_MIN, SkyhookParams, build_frames, chande01, skyhook_entries) + +# config V1 (vincente del lever-scout/grid; vedi diario 2026-06-23-skyhook) +V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + + +# --------------------------------------------------------------------------- +# Fedelta' al brief: indicatori tipo-Chande, normalizzati 0-100. +# --------------------------------------------------------------------------- +def test_chande01_anchors(): + """Semantica del brief: volatilita'/volume STEADY -> 50 (neutro); in RAMPA -> 100; in CALO -> 0.""" + n = 100 + assert abs(chande01(np.full(n, 7.0), 13)[-1] - 50.0) < 1e-9 # costante -> neutro + assert abs(chande01(np.arange(n, dtype=float), 13)[-1] - 100.0) < 1e-9 # rampa su -> 100 + assert abs(chande01(np.arange(n, 0, -1, dtype=float), 13)[-1] - 0.0) < 1e-9 # rampa giu' -> 0 + + +def test_demo_buzvola_buzvolume(): + """Ancore della demo: ATR costante (vol steady) -> BuzVola 50; volume in rampa -> BuzVolume 100.""" + n = 100 + buz_vola = chande01(np.full(n, 2.0), 13) # ATR steady + buz_volume = chande01(np.linspace(1000, 5000, n), 13) # volume in rampa + assert abs(buz_vola[-1] - 50.0) < 1e-9 + assert abs(buz_volume[-1] - 100.0) < 1e-9 + # oscillatori sempre in [0,100] + assert chande01(np.random.default_rng(0).normal(size=500).cumsum() + 100, 13)[20:].min() >= -1e-9 + assert chande01(np.random.default_rng(1).normal(size=500).cumsum() + 100, 13)[20:].max() <= 100 + 1e-9 + + +# --------------------------------------------------------------------------- +# Allineamento dual-timeframe: 690 = 3 x 230, confini HTF subset dei confini LTF. +# --------------------------------------------------------------------------- +def test_dual_tf_alignment(): + assert HTF_MIN == 3 * LTF_MIN + ltf, htf = build_frames(load_data("BTC", "5m")) + # ogni timestamp (open) HTF e' anche un open LTF (stessa griglia epoch) + ltf_opens = set(ltf["timestamp"].astype("int64").tolist()) + htf_opens = htf["timestamp"].astype("int64").tolist() + inside = sum(t in ltf_opens for t in htf_opens) + assert inside / len(htf_opens) > 0.99, "i confini HTF devono essere un sottoinsieme dei confini LTF" + + +# --------------------------------------------------------------------------- +# Causalita': gli ingressi su un prefisso devono coincidere con la run completa. +# --------------------------------------------------------------------------- +def test_no_lookahead_entries(): + p = SkyhookParams(**V1) + ltf, htf = build_frames(load_data("BTC", "5m")) + full = skyhook_entries(ltf, htf, p) + n = len(ltf) + cut = int(n * 0.85) + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + sub = skyhook_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p) + for i in range(cut - 200, cut): + a, b = full[i], sub[i] + assert (a is None) == (b is None) + if a is not None: + assert a["dir"] == b["dir"] + assert abs(a["sl"] - b["sl"]) < 1e-6 and abs(a["tp"] - b["tp"]) < 1e-6 + + +# --------------------------------------------------------------------------- +# Robustezza onesta del config V1: PASS su BTC E ETH, netto fee, OOS. +# --------------------------------------------------------------------------- +def test_v1_robust_both_assets(): + import skyhooklib as sk + p = SkyhookParams(**V1) + for a in ("BTC", "ETH"): + r = sk.run_asset(a, p, sk.FEE_RT) + assert r["full"]["sharpe"] >= 0.5, f"{a} FULL Sharpe basso: {r['full']['sharpe']}" + assert r["holdout"]["sharpe"] >= 0.2, f"{a} HOLD-OUT Sharpe basso: {r['holdout']['sharpe']}" + assert r["full"]["n_trades"] >= 20, f"{a} troppo pochi trade: {r['full']['n_trades']}" + assert sk.causality(p, "BTC")["ok"] and sk.causality(p, "ETH")["ok"] From 8e46a62e6758650f406e1c76363daac36f823f8e Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 23 Jun 2026 14:47:41 +0000 Subject: [PATCH 4/7] docs(skyhook): diario porting SKH01 + V1 (sintesi onda agenti in aggiornamento) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-23-skyhook.md | 70 ++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/diary/2026-06-23-skyhook.md diff --git a/docs/diary/2026-06-23-skyhook.md b/docs/diary/2026-06-23-skyhook.md new file mode 100644 index 0000000..aa64efd --- /dev/null +++ b/docs/diary/2026-06-23-skyhook.md @@ -0,0 +1,70 @@ +# 2026-06-23 — SKH01 "Skyhook": porting onesto del sistema ES dual-timeframe su BTC/ETH + +Branch: `strategy_skyhook`. Engine: `src/strategies/skyhook.py`. Harness: `scripts/research/skyhook/skyhooklib.py`. +Test: `tests/test_skyhook.py` (5 pass). Ricerca: `scripts/research/skyhook/{sweep,grid,check_v1}.py` + `runs/`. + +## Il brief + +Sistema "Skyhook" (origine ES / E-mini S&P, genetico, a doppio timeframe), da portare su crypto: +- **data2 = 690 min (segnale)**, **data1 = 230 min (esecuzione)**. NB **690 = 3 × 230**. +- NON trend-follower: entra **solo** quando coincidono (a) un **regime** di volatilità/volume e + (b) un **pattern** di breakout. +- Pipeline per barra: indicatori (BuzVola su ATR, BuzVolume su volume, tipo-Chande 0-100) → + fasce regime → pattern (Donchian/breakout su data2) → composer (regime AND pattern) → + ingresso (max 1/giorno, stop-and-reverse) → uscite (time-based asimmetrico uscitalong=24 / + uscitashort=18 + stop/profit). +- Ancore demo: trend lineare → **BuzVola=50** (vol steady → neutro), **BuzVolume=100** (volume in rampa). + +## Ricostruzione (fedele + onesta) + +- **Resample dal feed 5m certificato** con `origin='epoch'`: 230 min = 46×5m, 690 min = 138×5m, + e i confini 690 sono un **sottoinsieme** dei confini 230 → una barra HTF chiude esattamente su + una chiusura LTF. Merge HTF→LTF causale: `merge_asof` backward sulla **chiusura HTF** (≤ chiusura + LTF), così una barra HTF è usata solo quando è davvero chiusa. (~2287 barre/anno LTF, ~762 HTF.) +- **BuzVola / BuzVolume = `chande01`** (Chande Momentum Oscillator normalizzato 0-100): serie + steady → 50, rampa-su → 100, rampa-giù → 0. Le ancore demo sono soddisfatte a livello di + indicatore (è la lettura fedele: "vol steady → neutro"). NB: l'EMA-ATR su un *linspace* sintetico + dà 100 per drift di warm-up/floating-point, non per comportamento reale — su BTC reale BuzVola + oscilla intorno a 50 (EMA-ATR vs SMA-ATR corr 0.90). +- **Pattern** = Donchian breakout leak-free (shift(1)) su HTF, `ptn_n` barre (default 13 da 13/13/1). +- **Regime** = bande-soglia tunabili su BuzVola/BuzVolume (i magici interi 4/3/2 - 4/2/2 non sono + nel brief; ricostruiti come `[vola_lo,vola_hi]` × `[vol_lo,vol_hi]`). +- **Composer** = regime AND pattern. **Ingressi** ≤1/giorno (prima barra qualificante). +- **Uscite**: time-based asimmetrico (`uscitalong`/`uscitashort` barre LTF) + hard stop/profit. Lo + "stop 2000 / profit 5000" in $ del sistema ES → **multipli di ATR LTF** (scale-free): default + `sl_atr=2.0`, `tp_atr=5.0` (~ rapporto 40:100 pt ES), con modalità `pct` alternativa. +- Engine espresso come **entries `{dir,tp,sl,max_bars}`** per `backtest_signals` (motore onesto del + progetto: TP/SL intrabar, max_bars, non-overlap). Causalità verificata con prefix-recompute + (0 mismatch). + +## Baseline → V1 (lever scout + grid, inline, veloce) + +- **Baseline** (default 13/13, sl2/tp5, vola[35,95], vol_lo50): causale, fee-surviving, FULL Sharpe + BTC +0.91 / ETH +0.64, ma **HOLD-OUT debole** (BTC −0.09 / ETH +0.17) → FAIL del gate onesto. +- **Lever scout** (`sweep.py`): gli **short servono** (long_only → HOLD −0.52); il **regime gate + conta** (togliere la banda vola → HOLD −0.80); il **floor di volume** a 50 *frenava* l'hold-out + (vol_lo=40 o 0 → PASS); **breakout più lento** (ptn_n=55) e **stop più larghi** (sl2.5/tp6) + alzano l'hold-out. +- **Grid combinato** (`grid.py`): vincitrice **SKH01-V1** + `SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0)`: + - **min-asset FULL +0.69, HOLD-OUT +0.64** (BTC 0.64 / ETH 0.64), **PASS**, fee-surviving a 0.30%RT. + - BTC FULL +0.69/+275% DD49% ; ETH FULL +1.01/+871% DD31% ; entrambi HOLD-OUT positivi. + - **Marginal vs TP01 = ADDS** e regge i gate induriti: **corr 0.06** (ortogonale, NON trend-beta), + `has_insample_edge=True` (Sharpe in-sample standalone 1.15), `is_hedge=False`, multi-cut + persistente. Blend **0.75·TP01 + 0.25·SKH01: HOLD Sharpe 0.31 → 0.74 (+0.44), DD 11.9%**; + blend 50/50 HOLD 0.88, DD 17.8%. + - Unico sub-gate fallito: `clean_year_uplift` +0.014 (sotto 0.02) → `earns_slot=False` per un pelo, + nonostante tutto il resto sia forte. **Debolezza principale: DD standalone alto (40-49%).** + +→ SKH01 è un **diversificatore quasi-ortogonale** reale (non un TP01 travestito): da solo è +volatile, ma come sleeve al 25% migliora moltissimo l'hold-out del portafoglio a DD bassissimo. + +## Onda multi-agente (30 agenti + verifica avversariale) — vedi sotto + +[Sintesi in aggiornamento al termine del workflow `skyhook-improve`: obiettivi = battere V1 +sull'hold-out, **tagliare il DD <30%**, e portare `earns_slot=True`. Famiglie: param (RR, ptn_n, +regime bands, exit bars, chande, local), regime-redef (percentile, realized-vol, vol-expansion, +LTF, stochastic, volume-z), pattern (confirmation, range-expansion, ROC, Keltner, NR, dual), +exit (trailing/chandelier, breakeven, R-multiple, regime-stop, time-only), sizing/overlay +(vol-target, trend-filter, asimmetria L/S, DVOL gate, DD kill-switch, day-of-week). Ogni +candidato passato per 2 scettici avversariali (window-luck/jackknife + causalità/fee/plateau).] From de72e3ce1f02392f9f0e581ce1c5b2333f580e34 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 23 Jun 2026 16:10:38 +0000 Subject: [PATCH 5/7] =?UTF-8?q?feat(skyhook):=20SKH01-V2-DD=20=E2=80=94=20?= =?UTF-8?q?asymmetric=20%-exits=20cut=20standalone=20DD=20<30%=20(2-wave?= =?UTF-8?q?=20agent=20research)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/diary/2026-06-23-skyhook.md | 72 +++- scripts/research/skyhook/runs/SKH2_ASYM_LS.py | 289 +++++++++++++ .../research/skyhook/runs/SKH2_CHANDE_WIN.py | 225 ++++++++++ scripts/research/skyhook/runs/SKH2_DDKILL.py | 381 +++++++++++++++++ .../research/skyhook/runs/SKH2_DUALTF_PTN.py | 399 ++++++++++++++++++ .../research/skyhook/runs/SKH2_ENS_PARAM.py | 285 +++++++++++++ .../research/skyhook/runs/SKH2_ENS_STRUCT.py | 375 ++++++++++++++++ .../research/skyhook/runs/SKH2_EXPAND_DD.py | 258 +++++++++++ scripts/research/skyhook/runs/SKH2_FREQ.py | 206 +++++++++ .../research/skyhook/runs/SKH2_KELTNER_PTN.py | 277 ++++++++++++ .../skyhook/runs/SKH2_PATTERN_CONF.py | 298 +++++++++++++ scripts/research/skyhook/runs/SKH2_PCTL_DD.py | 289 +++++++++++++ .../skyhook/runs/SKH2_REGIME_TIGHT.py | 163 +++++++ scripts/research/skyhook/runs/SKH2_TPSL_DD.py | 191 +++++++++ scripts/research/skyhook/runs/SKH2_VOLTGT.py | 322 ++++++++++++++ scripts/research/skyhook/runs/SKH_P_CHANDE.py | 102 +++++ .../research/skyhook/runs/SKH_P_EXITBARS.py | 100 +++++ scripts/research/skyhook/runs/SKH_P_LOCAL.py | 76 ++++ scripts/research/skyhook/runs/SKH_P_LOCAL2.py | 65 +++ .../skyhook/runs/SKH_P_LOCAL_final.py | 48 +++ .../skyhook/runs/SKH_P_LOCAL_v1ref.py | 18 + .../skyhook/runs/SKH_P_LOCAL_winner.py | 26 ++ scripts/research/skyhook/runs/SKH_P_REGIME.py | 107 +++++ .../skyhook/runs/SKH_P_REGIME_plateau.py | 46 ++ scripts/research/skyhook/runs/SKH_R_EXPAND.py | 306 ++++++++++++++ .../skyhook/runs/SKH_R_EXPAND_study.py | 72 ++++ scripts/research/skyhook/runs/SKH_R_PCTL.py | 308 ++++++++++++++ .../research/skyhook/runs/SKH_R_PCTL_final.py | 102 +++++ scripts/research/skyhook/runs/SKH_R_RV.py | 281 ++++++++++++ src/strategies/skyhook.py | 41 +- tests/test_skyhook.py | 53 ++- 31 files changed, 5768 insertions(+), 13 deletions(-) create mode 100644 scripts/research/skyhook/runs/SKH2_ASYM_LS.py create mode 100644 scripts/research/skyhook/runs/SKH2_CHANDE_WIN.py create mode 100644 scripts/research/skyhook/runs/SKH2_DDKILL.py create mode 100644 scripts/research/skyhook/runs/SKH2_DUALTF_PTN.py create mode 100644 scripts/research/skyhook/runs/SKH2_ENS_PARAM.py create mode 100644 scripts/research/skyhook/runs/SKH2_ENS_STRUCT.py create mode 100644 scripts/research/skyhook/runs/SKH2_EXPAND_DD.py create mode 100644 scripts/research/skyhook/runs/SKH2_FREQ.py create mode 100644 scripts/research/skyhook/runs/SKH2_KELTNER_PTN.py create mode 100644 scripts/research/skyhook/runs/SKH2_PATTERN_CONF.py create mode 100644 scripts/research/skyhook/runs/SKH2_PCTL_DD.py create mode 100644 scripts/research/skyhook/runs/SKH2_REGIME_TIGHT.py create mode 100644 scripts/research/skyhook/runs/SKH2_TPSL_DD.py create mode 100644 scripts/research/skyhook/runs/SKH2_VOLTGT.py create mode 100644 scripts/research/skyhook/runs/SKH_P_CHANDE.py create mode 100644 scripts/research/skyhook/runs/SKH_P_EXITBARS.py create mode 100644 scripts/research/skyhook/runs/SKH_P_LOCAL.py create mode 100644 scripts/research/skyhook/runs/SKH_P_LOCAL2.py create mode 100644 scripts/research/skyhook/runs/SKH_P_LOCAL_final.py create mode 100644 scripts/research/skyhook/runs/SKH_P_LOCAL_v1ref.py create mode 100644 scripts/research/skyhook/runs/SKH_P_LOCAL_winner.py create mode 100644 scripts/research/skyhook/runs/SKH_P_REGIME.py create mode 100644 scripts/research/skyhook/runs/SKH_P_REGIME_plateau.py create mode 100644 scripts/research/skyhook/runs/SKH_R_EXPAND.py create mode 100644 scripts/research/skyhook/runs/SKH_R_EXPAND_study.py create mode 100644 scripts/research/skyhook/runs/SKH_R_PCTL.py create mode 100644 scripts/research/skyhook/runs/SKH_R_PCTL_final.py create mode 100644 scripts/research/skyhook/runs/SKH_R_RV.py diff --git a/docs/diary/2026-06-23-skyhook.md b/docs/diary/2026-06-23-skyhook.md index aa64efd..efbc68d 100644 --- a/docs/diary/2026-06-23-skyhook.md +++ b/docs/diary/2026-06-23-skyhook.md @@ -59,12 +59,68 @@ Sistema "Skyhook" (origine ES / E-mini S&P, genetico, a doppio timeframe), da po → SKH01 è un **diversificatore quasi-ortogonale** reale (non un TP01 travestito): da solo è volatile, ma come sleeve al 25% migliora moltissimo l'hold-out del portafoglio a DD bassissimo. -## Onda multi-agente (30 agenti + verifica avversariale) — vedi sotto +## Onda 1 (`skyhook-improve`, 30 agenti) — winner intermedio -[Sintesi in aggiornamento al termine del workflow `skyhook-improve`: obiettivi = battere V1 -sull'hold-out, **tagliare il DD <30%**, e portare `earns_slot=True`. Famiglie: param (RR, ptn_n, -regime bands, exit bars, chande, local), regime-redef (percentile, realized-vol, vol-expansion, -LTF, stochastic, volume-z), pattern (confirmation, range-expansion, ROC, Keltner, NR, dual), -exit (trailing/chandelier, breakeven, R-multiple, regime-stop, time-only), sizing/overlay -(vol-target, trend-filter, asimmetria L/S, DVOL gate, DD kill-switch, day-of-week). Ogni -candidato passato per 2 scettici avversariali (window-luck/jackknife + causalità/fee/plateau).] +Famiglie: param (RR, ptn_n, regime bands, exit bars, chande, local), regime-redef (percentile, +realized-vol, vol-expansion, LTF), pattern (confirmation, ROC, Keltner, NR, dual), exit + overlay, +ognuna verificata da 2 scettici. Risultato: **winner intermedio** +`SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16, vola_lo=35, vola_hi=95, vol_lo=0)` +— **minFull +0.83, minHold +0.81** (vs V1 +0.69/+0.64), causale, fee-surviving 0.30%RT, marginal +**ADDS** (corr 0.05, has_insample_edge, robust_oos, multicut, clean_year_uplift +0.37), blend w25 +uplift_hold +0.58. **MA standalone maxDD ancora 34% (BTC) / 31% (ETH) → l'unico goal mancato era il DD<30%.** + +## Onda 2 (`skyhook-improve-v2`, 14 famiglie DD-reduction) — SKH01-V2-DD vince + +Obiettivo: tagliare il **DD standalone <30%** tenendo hold-out + `earns_slot`, e alzare l'uplift di +portafoglio. 14 famiglie (ensemble param/struct, vol-target, DD kill-switch, RR/stop grid, regime +tight, percentile, vol-expansion, breakout confirmation, dual-TF, asimmetria L/S, cadenza, chande, +Keltner), ognuna verificata da 2 scettici avversariali (window-luck/multicut/jackknife + +causalità/fee/plateau/overfit). Esito: **il winner intermedio cade.** Nuovo campione **SKH01-V2-DD** +(famiglia ASYM_LS, `src/strategies/skyhook.py:SKH01_V2_DD`, run `runs/SKH2_ASYM_LS.py`): + +- **Config:** stesso SEGNALE del winner (`ptn_n=45, vola_lo=35, vola_hi=95, vol_lo=0, exit-bars 24/16`) + ma EXIT commutati da ATR a **percentuale fissa ASIMMETRICA** — long `sl=4% / tp=10%`, short + `sl=2% (più stretto) / tp=8%`. Motivazione meccanica: in crypto lo short si fa steamrollare da uno + spike vola e lo stop-ATR si allarga lasciando correre la perdita → il %-SL stretto sullo short + **cappa la perdita per-trade** che FORMA il maxDD. (Implementato come override per-direzione nel + motore, backward-compatible: campi `*_short=None` → comportamento simmetrico invariato.) +- **Numeri veri (verificati indipendentemente via `sk.study(SKH01_V2_DD)`):** standalone maxDD + **BTC 21.4% / ETH 27.4%** (<30% ✓, vs 34.4/30.5 del winner) — **goal RAGGIUNTO**; minFull **+0.99**, + minHold **+1.26**; causalità **0/400** entrambi gli asset; fee@0.30%RT BTC +1.05 / ETH +0.80 + (positiva anche a 0.40%). Marginal vs TP01 **ADDS** (corr 0.09, has_insample_edge, is_hedge=False, + robust_oos, multicut, clean_year_uplift +0.57). **Blend 0.75·TP01 + 0.25·SKH: uplift_hold +0.87** + (vs +0.58 del winner); **blend 50/50: full 1.84 / hold 1.59 / DD 10.7%**. earns_slot=True, + beats_winner=True. **Plateau reale** (i vicini Spct_mb14/16 sl2% tengono DD 27-28%), non knife-edge. + Entrambi gli scettici: holds_up=True, confidence high, killer_finding=null. + +**Top-3 dell'onda 2 (criteri onesti):** + +| # | Famiglia | maxDD (BTC/ETH) | minHold | w25 uplift_hold | Verifica | +|---|---|---|---|---|---| +| **1** | **ASYM_LS → SKH01-V2-DD** | 27.4% (21.4/27.4) | +1.26 | **+0.87** | 2/2 high, killer=null ✅ | +| 2 | ENS_STRUCT (3-regime ensemble) | **22.9%** (21.2/22.8) | +1.00 | +0.67 | 2/2 high — ma 3 motori da eseguire | +| 3 | TPSL_DD (%-SL/TP hard) | 28.0% (28/25.5) | +1.11 | +0.75 | 1/1 (rate-limit) — caveat hedge-like | + +**Lezioni anti-DD:** +- **Ha funzionato (STRUTTURA dell'exit, non i parametri):** cambiare il MECCANISMO di uscita — %-SL + hard, asimmetria L/S, o ensemble di exit/regime diversi (decorrelazione). Il DD del winner nasce + dalla coda intra-trade negli spike ATR; il %-SL la cappa. +- **NON ha funzionato (la leva non raggiunge il DD vincolante):** DD kill-switch entry-only (sopprime + solo le NUOVE entry, non chiude il trade aperto che forma il maxDD → floor 33-36%); vol-target + causale (DD<30 e uplift≥0.55 mutuamente esclusivi; cap>1 PEGGIORA il DD levereggiando nel pre-crash); + cadenza/FREQ (accorciare gli hold short fa esplodere ETH a 50-66%); dual-TF (LTF è resample dello + stesso prezzo → quasi-tautologico, DD invariato). +- **Bocciato dagli scettici come overfit:** PATTERN_CONF (sub-30 solo a vola_lo=45, knife-edge: sl_atr + ±0.5 → ETH 40-47%; la conferma "close_loc" da sola NON taglia il DD). Esempio canonico del perché + serviva la doppia verifica. +- **Non promuovibili:** PCTL_DD (numeri spettacolari ma **0 verifiche**, le 2 sono morte per rate-limit + → forward-monitor, non fidato); ENS_PARAM / TPSL_DD (battono i gate ma uplift recency/hedge-loaded, + concentrato nei regimi TP01-down → forward-monitor). + +**Promozione (questa sessione):** `SKH01_V2_DD` canonico nel motore + override exit-short +asimmetrici (backward-compatible, V1/winner invariati) + 3 test nuovi (8/8 pass). +**Caveat onesti / NON ancora deployato:** ETH DD 27.4% ha margine sottile vs 30% (BTC 21.4% comodo) → +monitorare ETH. Lo sleeve di portafoglio NON è ancora cablato in `active_sleeves`: prima del deploy va +replicato il blend a peso 0.25 nel portafoglio attivo coi dati live e ri-verificata la causalità sul +**codice di esecuzione reale** (le verifiche qui sono sull'harness di ricerca). Per ora: research win, +candidato sleeve forward-monitor. diff --git a/scripts/research/skyhook/runs/SKH2_ASYM_LS.py b/scripts/research/skyhook/runs/SKH2_ASYM_LS.py new file mode 100644 index 0000000..503909a --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_ASYM_LS.py @@ -0,0 +1,289 @@ +"""SKH2_ASYM_LS — long/short RISK ASYMMETRY family (Skyhook DD-cut wave). + +Hypothesis: shorts are essential (prior finding) but they carry the standalone draw-down — +in crypto a short gets steamrolled by a vol-up move. Keep the verified V2-winner risk on the +LONG side, but put TIGHTER risk on the SHORT side: a shorter time-stop (uscitashort) and/or a +tighter SL (smaller sl_atr, or a fixed 'pct' SL), and a leaner TP so shorts take profit fast +instead of bleeding into a reversal. + +SkyhookParams has uscitalong/uscitashort but a SINGLE sl_atr/tp_atr, so direction-asymmetric +STOPS require CUSTOM entries. We reuse the engine's regime+pattern signal (htf_features + +merge_htf_to_ltf) UNCHANGED — only the per-direction (sl, tp, max_bars) differ. This is causal: +the only thing that depends on direction is the offset magnitude applied to close[i]; the SIGNAL +(comp_long/comp_short) is computed exactly as the verified winner. + +Causality: proven by truncated-prefix recompute on the CUSTOM entries (same scheme as +sk.causality): an entry emitted on a prefix must match the full-run entry at that index. +""" +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.backtest.harness import backtest_signals +from src.strategies import skyhook as S +from src.strategies.skyhook import SkyhookParams + +HOLDOUT = sk.HOLDOUT +FEE = sk.FEE_RT + +# Verified V2 winner signal config (the regime/pattern gate we keep). +WIN = dict(ptn_n=45, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) +# Winner's symmetric risk (used for longs, and as the symmetric reference): +WIN_RISK = dict(sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16) + + +# --------------------------------------------------------------------------- +# Custom asymmetric entries. Longs keep `long_risk`; shorts use `short_risk`. +# Risk dicts: {'mode':'atr'|'pct', 'sl':..., 'tp':..., 'mb':int} +# atr -> sl/tp are ATR multiples ; pct -> sl/tp are fractions of close. +# --------------------------------------------------------------------------- +def asym_entries(ltf, htf, base_p: SkyhookParams, long_risk: dict, short_risk: dict) -> list: + feat = S.htf_features(htf, base_p) + m = S.merge_htf_to_ltf(ltf, feat) + c = m["close"].values.astype(float) + a = S.atr(m, base_p.ltf_atr_win) + comp_long = np.nan_to_num(m["comp_long"].values).astype(bool) + comp_short = np.nan_to_num(m["comp_short"].values).astype(bool) + days = pd.to_datetime(m["datetime"]).dt.floor("D").values + + entries = [None] * len(m) + count_today: dict = {} + for i in range(len(m)): + if not np.isfinite(a[i]) or a[i] <= 0: + continue + day = days[i] + if count_today.get(day, 0) >= base_p.max_per_day: + continue + if comp_long[i]: + direction, rk = 1, long_risk + elif comp_short[i]: + direction, rk = -1, short_risk + else: + continue + if rk["mode"] == "atr": + sl_off, tp_off = rk["sl"] * a[i], rk["tp"] * a[i] + else: + sl_off, tp_off = rk["sl"] * c[i], rk["tp"] * c[i] + if direction == 1: + sl, tp = c[i] - sl_off, c[i] + tp_off + else: + sl, tp = c[i] + sl_off, c[i] - tp_off + entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(rk["mb"])} + count_today[day] = count_today.get(day, 0) + 1 + return entries + + +# --------------------------------------------------------------------------- +# Causality on the CUSTOM entries: prefix recompute must match the full run. +# --------------------------------------------------------------------------- +def causality_struct(base_p, long_risk, short_risk, asset="BTC", tail=200) -> dict: + ltf, htf = sk.frames(asset) + full = asym_entries(ltf, htf, base_p, long_risk, short_risk) + n = len(ltf) + bad = 0 + checked = 0 + for frac in (0.80, 0.92): + cut = int(n * frac) + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + sub = asym_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, base_p, long_risk, short_risk) + for i in range(max(0, cut - tail), cut): + checked += 1 + x, y = full[i], sub[i] + if (x is None) != (y is None): + bad += 1 + elif x is not None and (x["dir"] != y["dir"] + or abs(x["sl"] - y["sl"]) > 1e-6 + or abs(x["tp"] - y["tp"]) > 1e-6 + or x["max_bars"] != y["max_bars"]): + bad += 1 + return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) + + +def _split(eq, idx, mask): + e = eq[mask] + if len(e) < 5: + return dict(sharpe=0.0, ret=0.0, maxdd=0.0) + r = np.diff(e) / e[:-1] + r = r[np.isfinite(r)] + ix = idx[mask] + dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400 + bpy = 86400 * 365.25 / dt + sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 + pk = np.maximum.accumulate(e) + dd = float(np.max((pk - e) / pk)) + return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4)) + + +def study_asym(name, base_p, long_risk, short_risk): + per_asset = {} + fee_ok_all = True + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = asym_entries(ltf, htf, base_p, long_risk, short_risk) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = _split(eq, idx, hmask) + sweep = {} + for f in (0.0, 0.001, 0.002, 0.003): + mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m") + sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3) + fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0) + # short-only vs long-only DD diagnostic + per_asset[a] = dict(full=full, hold=hold, fee_sweep=sweep, + yearly={int(y): round(v, 4) for y, v in m.yearly.items()}) + mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset) + mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset) + mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset) + mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset) + grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \ + ("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL") + return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all, + per_asset=per_asset) + + +def daily_5050(base_p, long_risk, short_risk): + series = {} + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = asym_entries(ltf, htf, base_p, long_risk, short_risk) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + series[a] = s.resample("1D").last().ffill().pct_change().dropna() + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return 0.5 * J["BTC"] + 0.5 * J["ETH"] + + +def marginal_asym(base_p, long_risk, short_risk): + return al.marginal_vs_tp01(daily_5050(base_p, long_risk, short_risk)) + + +def print_study(name, r): + print(f"\n=== {name} -> {r['grade']} (minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f}" + f" minTr={r['minTr']} maxDD={r['maxDD']*100:.0f}% feeOK={r['fee_ok']})") + for a, pa in r["per_asset"].items(): + yr = " ".join(f"{y}:{v*100:+.0f}%" for y, v in pa["yearly"].items()) + print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}%" + f" DD={pa['full']['maxdd']*100:.0f}% n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}%" + f" | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}%") + print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items())) + print(f" yr: {yr}") + + +if __name__ == "__main__": + base_p = SkyhookParams(**WIN, **WIN_RISK) # signal + winner risk (used for shape only) + + # ---- 0) REFERENCE: rebuild the verified symmetric winner via our custom path ------- + long_winner = dict(mode="atr", sl=2.5, tp=7.0, mb=24) + sym_short = dict(mode="atr", sl=2.5, tp=7.0, mb=16) + rREF = study_asym("REF symmetric-winner (rebuilt)", base_p, long_winner, sym_short) + print_study("REF symmetric-winner (rebuilt)", rREF) + + # Long side: ROUND 1 showed pct-SL shorts lift everything but ETH DD sticks ~30.5%. + # The standalone DD comes from BOTH directions, so we also tighten the LONG pct-SL a + # touch to bring the combined DD under 30 while keeping the winner's long TP behaviour. + # We test two long variants: the verified winner (atr) AND a pct long. + long_variants = [ + ("Latr", dict(mode="atr", sl=2.5, tp=7.0, mb=24)), + ("Lpct", dict(mode="pct", sl=0.04, tp=0.10, mb=24)), + ] + + # ---- 1) GRID over asymmetric SHORT risk: pct-SL family is the winner; push SL tighter + # to knock ETH DD under 30. Keep the strong tp=0.08 and a couple of mb / SL choices. + short_grid = [] + for mb_s in (12, 14, 16): + for slp in (0.02, 0.025, 0.03): + for tpp in (0.06, 0.08): + short_grid.append((f"Spct_mb{mb_s}_sl{slp}_tp{tpp}", + dict(mode="pct", sl=slp, tp=tpp, mb=mb_s))) + # a few tight-ATR shorts for completeness + for mb_s in (12, 14): + for sl_s in (1.5, 2.0): + short_grid.append((f"Satr_mb{mb_s}_sl{sl_s}_tp5.0", + dict(mode="atr", sl=sl_s, tp=5.0, mb=mb_s))) + + candidates = [] + for lname, lr in long_variants: + for sname, sr in short_grid: + candidates.append((f"{lname}|{sname}", lr, sr)) + + results = [] + for name, lr, sr in candidates: + r = study_asym(name, base_p, lr, sr) + results.append((name, lr, sr, r)) + + # Rank: feasible (grade != FAIL, fee ok) by lowest DD, then highest minHold. + feas = [(n, lr, sr, r) for n, lr, sr, r in results if r["grade"] != "FAIL"] + feas.sort(key=lambda t: (t[3]["maxDD"], -t[3]["minHold"])) + + print("\n\n##### GRID RANK (feasible, by lowest standalone maxDD) #####") + for n, lr, sr, r in feas[:16]: + print(f" {n:28s} DD={r['maxDD']*100:4.0f}% minFull={r['minFull']:+.2f}" + f" minHold={r['minHold']:+.2f} minTr={r['minTr']} grade={r['grade']}") + + # ---- 2) Detailed study + marginal on the top DD-cutters that keep hold-out --------- + # pick best candidates: DD<30 with decent hold-out + qualifying = [t for t in feas if t[3]["maxDD"] < 0.30 and t[3]["minHold"] >= 0.50] + qualifying.sort(key=lambda t: (t[3]["maxDD"], -t[3]["minHold"])) + probe = qualifying[:5] if qualifying else feas[:5] + + print("\n\n##### DETAIL + MARGINAL on top probes #####") + best = None + for n, long_risk, sr, r in probe: + print_study(n, r) + caus = causality_struct(base_p, long_risk, sr, "BTC") + caus_e = causality_struct(base_p, long_risk, sr, "ETH") + mg = marginal_asym(base_p, long_risk, sr) + w25 = mg.get("blends", {}).get("w25", {}) + w50 = mg.get("blends", {}).get("w50", {}) + earns = (r["grade"] != "FAIL" and mg.get("marginal_verdict") == "ADDS" + and mg.get("robust_oos") and not mg.get("is_hedge")) + beats = bool(earns and r["maxDD"] < 0.30 + and (w25.get("uplift_hold") or -9) >= 0.55 + and r["minHold"] >= 0.65) + print(f" CAUSALITY BTC={caus} ETH={caus_e}") + print(f" MARGINAL: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')}" + f" insample_edge={mg.get('has_insample_edge')} cand_is_sh={mg.get('cand_insample_sharpe')}" + f" hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')}" + f" multicut={mg.get('multicut_persistent')} cleanYr={mg.get('clean_year_uplift')}") + print(f" BLEND w25: uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')}" + f" | w50: full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}") + print(f" => earns_slot={earns} beats_winner={beats}") + cand = dict(name=n, long_risk=long_risk, short_risk=sr, study=r, caus=caus, caus_e=caus_e, + mg=mg, earns=earns, beats=beats) + # prefer beats; else lowest-DD earns; else lowest-DD feasible + if best is None: + best = cand + else: + key = lambda x: (x["beats"], x["earns"], -x["study"]["maxDD"], x["study"]["minHold"]) + if key(cand) > key(best): + best = cand + + print("\n\n##### FINAL BEST #####") + b = best + r = b["study"] + mg = b["mg"] + w25 = mg.get("blends", {}).get("w25", {}) + w50 = mg.get("blends", {}).get("w50", {}) + print(f"BEST CONFIG: signal={WIN} long_risk={b['long_risk']} short_risk={b['short_risk']}") + print(f" minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f} maxDD={r['maxDD']*100:.0f}%" + f" minTrades={r['minTr']} fee@0.30%_ok={r['fee_ok']}") + print(f" causality BTC={b['caus']} ETH={b['caus_e']}") + print(f" marginal: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')}" + 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')}") + print(f" blend w25 uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')}" + f" | w50 full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}") + print(f" earns_slot={b['earns']} beats_winner={b['beats']}") + print(f" BTC_DD={r['per_asset']['BTC']['full']['maxdd']} ETH_DD={r['per_asset']['ETH']['full']['maxdd']}") diff --git a/scripts/research/skyhook/runs/SKH2_CHANDE_WIN.py b/scripts/research/skyhook/runs/SKH2_CHANDE_WIN.py new file mode 100644 index 0000000..5a5fb2f --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_CHANDE_WIN.py @@ -0,0 +1,225 @@ +"""SKH2_CHANDE_WIN — DD-reduction wave: re-tune indicator WINDOWS (Chande/ATR) for DD. + +Family task: smoother indicators -> more stable regime -> potentially lower standalone maxDD. +We hold the VERIFIED V2 winner's pattern/exits/bands FIXED and sweep ONLY the windows: + n_vola, n_volume in {7,13,21,34} + atr_win in {10,14,21} + ltf_atr_win in {10,14,21} + +Everything is expressible via SkyhookParams -> the SHARED honest harness sk.study() applies +the exact leak-free FULL+HOLDOUT+fee-sweep+per-year machinery, and sk.causality / sk.marginal +give the same comparable numbers as every other agent. + +WINNER (baseline to beat): + 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) + minFull +0.83 minHold +0.81 ; standalone DD BTC 34% / ETH 31% (>30% = the problem). + +GOAL: max_dd < 0.30 while keeping minHold >= ~0.70 and earns_slot True, blend w25 uplift_hold >= 0.55. +""" +from __future__ import annotations + +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") + +import itertools + +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +# ---- fixed winner spine (pattern / exits / bands) -------------------------- +FIXED = 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 mk(n_vola, n_volume, atr_win, ltf_atr_win): + return SkyhookParams(n_vola=n_vola, n_volume=n_volume, atr_win=atr_win, + ltf_atr_win=ltf_atr_win, **FIXED) + + +def cheap_eval(p): + """Fast standalone screen: FULL+HOLD on BTCÐ only (no fee-sweep/marginal).""" + rb = sk.run_asset("BTC", p) + re = sk.run_asset("ETH", p) + min_full = min(rb["full"]["sharpe"], re["full"]["sharpe"]) + min_hold = min(rb["holdout"]["sharpe"], re["holdout"]["sharpe"]) + max_dd = max(rb["full"]["maxdd"], re["full"]["maxdd"]) + min_tr = min(rb["full"]["n_trades"], re["full"]["n_trades"]) + return dict(min_full=min_full, min_hold=min_hold, max_dd=max_dd, min_tr=min_tr, + btc_dd=rb["full"]["maxdd"], eth_dd=re["full"]["maxdd"], + btc_hold=rb["holdout"]["sharpe"], eth_hold=re["holdout"]["sharpe"]) + + +def earns_slot(rep, marg): + return (rep["verdict"]["grade"] != "FAIL" + and marg.get("marginal_verdict") == "ADDS" + and bool(marg.get("robust_oos")) + and not bool(marg.get("is_hedge"))) + + +def beats_winner(rep, marg, ev): + 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 ev["max_dd"] < 0.30 and (w25 is not None and w25 >= 0.55) and mh >= 0.65) + + +# ---- WINNER reference (so DD comparison is apples-to-apples in THIS harness) ---- +def winner_params(): + return SkyhookParams(**FIXED) + + +if __name__ == "__main__": + print("########## STAGE 1: cheap window screen (FULL+HOLD+DD, BTCÐ) ##########") + + # winner reference in this harness + wev = cheap_eval(winner_params()) + print(f"[WINNER ref] minFull={wev['min_full']:+.2f} minHold={wev['min_hold']:+.2f} " + f"maxDD={wev['max_dd']*100:.0f}% (BTC {wev['btc_dd']*100:.0f}% / ETH {wev['eth_dd']*100:.0f}%) " + f"minTr={wev['min_tr']}") + + n_vola_grid = [7, 13, 21, 34] + n_volume_grid = [7, 13, 21, 34] + atr_grid = [10, 14, 21] + ltf_grid = [10, 14, 21] + + rows = [] + for nva, nvo, aw, law in itertools.product(n_vola_grid, n_volume_grid, atr_grid, ltf_grid): + p = mk(nva, nvo, aw, law) + ev = cheap_eval(p) + rows.append((nva, nvo, aw, law, ev)) + + # Sort by lowest DD among those that keep some hold-out edge & enough trades + def keyf(r): + ev = r[4] + return ev["max_dd"] + + viable = [r for r in rows if r[4]["min_tr"] >= 20] + viable.sort(key=keyf) + + print(f"\n--- {len(rows)} configs screened. Top 15 by LOWEST standalone maxDD " + f"(min_tr>=20) ---") + print(f"{'nva':>4}{'nvo':>4}{'aw':>4}{'law':>5} {'maxDD':>7} {'btcDD':>6} {'ethDD':>6} " + f"{'minFull':>8} {'minHold':>8} {'minTr':>6}") + for nva, nvo, aw, law, ev in viable[:15]: + print(f"{nva:>4}{nvo:>4}{aw:>4}{law:>5} {ev['max_dd']*100:>6.1f}% " + f"{ev['btc_dd']*100:>5.1f}% {ev['eth_dd']*100:>5.1f}% " + f"{ev['min_full']:>+8.2f} {ev['min_hold']:>+8.2f} {ev['min_tr']:>6}") + + # STAGE-1 LEARNING (from broad probes): no window combo gets BOTH BTCÐ sub-30% + # (BTC & ETH DD move in OPPOSITE directions vs n_vola/atr_win). The best DD-CUT that + # also keeps hold-out is the SLOWER-INDICATOR corner. Study the lowest-DD configs that + # still keep minHold>=0.50 (the real DD/hold tradeoff frontier), plus a couple extras + # found by the broad probe (n_vola=13, slower atr_win/ltf_atr_win). + extra = [(13, 13, 18, 18), (13, 13, 14, 18), (13, 13, 21, 18)] # (nva,nvo,aw,law) + # candidates that cut DD while keeping hold-out + cands = [r for r in viable + if r[4]["min_hold"] >= 0.50 and r[4]["min_full"] >= 0.50] + cands.sort(key=lambda r: r[4]["max_dd"]) # lowest DD first + study_keys = set() + study_list = [] + for r in cands[:5]: + k = (r[0], r[1], r[2], r[3]) + if k not in study_keys: + study_keys.add(k); study_list.append(r) + # ensure the broad-probe extras are studied (they may not be on the coarse grid) + for nva, nvo, aw, law in extra: + k = (nva, nvo, aw, law) + if k not in study_keys: + ev = cheap_eval(mk(nva, nvo, aw, law)) + study_keys.add(k); study_list.append((nva, nvo, aw, law, ev)) + if not study_list: + study_list = viable[:4] + + print(f"\n########## STAGE 2: FULL study + causality + marginal on " + f"{len(study_list)} candidate(s) ##########") + + results = [] + for nva, nvo, aw, law, ev in study_list: + p = mk(nva, nvo, aw, law) + name = f"CW_nva{nva}_nvo{nvo}_aw{aw}_law{law}" + rep = sk.study(name, p) + caus_b = sk.causality(p, "BTC") + caus_e = sk.causality(p, "ETH") + marg = sk.marginal(p) + caus_ok = bool(caus_b["ok"] and caus_e["ok"]) + es = earns_slot(rep, marg) + bw = beats_winner(rep, marg, ev) and caus_ok + w25 = marg.get("blends", {}).get("w25", {}).get("uplift_hold") + w50 = marg.get("blends", {}).get("w50", {}) + results.append(dict(name=name, p=p, rep=rep, marg=marg, ev=ev, + caus_ok=caus_ok, es=es, bw=bw, w25=w25, w50=w50, + cfg=dict(n_vola=nva, n_volume=nvo, atr_win=aw, ltf_atr_win=law))) + print("\n" + sk.fmt(rep)) + print(f" causality BTC={caus_b} ETH={caus_e}") + print(f" marginal: verdict={marg.get('marginal_verdict')} corr_full={marg.get('corr_full')} " + f"has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')} " + f"robust_oos={marg.get('robust_oos')} multicut_persistent={marg.get('multicut_persistent')}") + print(f" clean_year_uplift={marg.get('clean_year_uplift')} " + f"blend_w25_uplift_hold={w25} w50={w50}") + print(f" >> earns_slot={es} beats_winner={bw} standaloneDD={ev['max_dd']*100:.1f}%") + + # ---- pick best: prefer beats_winner; else lowest DD among earns_slot; else lowest DD ---- + def rank(r): + # higher is better. Priority: (1) beats_winner, (2) earns_slot, then PORTFOLIO VALUE + # (blend w25 uplift_hold + min-asset hold-out) which the wave objectives (2)&(3) reward, + # with DD as a final tiebreak. NOTE: no window combo reaches max_dd<0.30 (DD wall is + # structural: BTC & ETH DD move in OPPOSITE directions vs the vola window) so we report + # the strongest earns_slot config rather than chasing an unreachable DD gate. + w25 = r["w25"] if r["w25"] is not None else -9 + return (1 if r["bw"] else 0, + 1 if r["es"] else 0, + round(w25, 3), + r["rep"]["verdict"]["min_asset_holdout_sharpe"], + -r["ev"]["max_dd"]) + + if results: + best = sorted(results, key=rank, reverse=True)[0] + b = best + rep = b["rep"]; marg = b["marg"]; ev = b["ev"] + v = rep["verdict"] + print("\n" + "=" * 78) + print("FINAL BEST CONFIG (CHANDE_WIN family)") + print("=" * 78) + print(f" config = {b['cfg']} (+ fixed winner spine {FIXED})") + print(f" name = {b['name']}") + print(f" minFull = {v['min_asset_full_sharpe']:+.3f}") + print(f" minHold = {v['min_asset_holdout_sharpe']:+.3f} " + f"(BTC {rep['per_asset']['BTC']['holdout']['sharpe']:+.2f} / " + f"ETH {rep['per_asset']['ETH']['holdout']['sharpe']:+.2f})") + print(f" standalone max_dd = {ev['max_dd']:.4f} " + f"(BTC {ev['btc_dd']:.4f} / ETH {ev['eth_dd']:.4f})") + print(f" n_trades_min = {v['min_trades']}") + print(f" fee_survives 0.30%= {v['fee_survives']}") + print(f" causality_ok = {b['caus_ok']}") + print(f" grade = {v['grade']}") + print(f" --- marginal vs TP01 ---") + print(f" corr_full = {marg.get('corr_full')}") + print(f" marginal_verdict = {marg.get('marginal_verdict')}") + 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_persist = {marg.get('multicut_persistent')}") + print(f" clean_year_uplift = {marg.get('clean_year_uplift')}") + print(f" blend w25 uplift_hold = {b['w25']}") + print(f" blend w50 = {b['w50']}") + print(f" earns_slot = {b['es']}") + print(f" BEATS_WINNER = {b['bw']}") + print("=" * 78) + + # machine-readable line for the harness operator + import json + out = dict( + family="CHANDE_WIN", best_config=b["cfg"], fixed=FIXED, name=b["name"], + grade=v["grade"], min_full=v["min_asset_full_sharpe"], + min_hold=v["min_asset_holdout_sharpe"], max_dd=ev["max_dd"], + btc_dd=ev["btc_dd"], eth_dd=ev["eth_dd"], n_trades_min=v["min_trades"], + fee_survives=v["fee_survives"], causality_ok=b["caus_ok"], + corr_full=marg.get("corr_full"), marginal_verdict=marg.get("marginal_verdict"), + has_insample_edge=marg.get("has_insample_edge"), is_hedge=marg.get("is_hedge"), + robust_oos=marg.get("robust_oos"), + multicut_persistent=marg.get("multicut_persistent"), + clean_year_uplift=marg.get("clean_year_uplift"), + blend_w25_uplift_hold=b["w25"], earns_slot=b["es"], beats_winner=b["bw"], + ) + print("RESULT_JSON " + json.dumps(out, default=str)) diff --git a/scripts/research/skyhook/runs/SKH2_DDKILL.py b/scripts/research/skyhook/runs/SKH2_DDKILL.py new file mode 100644 index 0000000..8f3cd05 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_DDKILL.py @@ -0,0 +1,381 @@ +"""SKH2_DDKILL — CAUSAL drawdown kill-switch overlay on Skyhook entries. + +Family: drawdown kill-switch on entries [DDKILL]. + +Idea +---- +Walk the trade-by-trade REALIZED equity of the V2-winner Skyhook. Track the running peak. +Once standalone DD from the running peak exceeds `dd_kill`, enter a "killed" state and SUPPRESS +new entries until equity recovers within `recover` of the running peak (i.e. DD shrinks back +below `recover`). This is sequential & causal: the kill decision for a new entry at bar i uses +ONLY the equity realized by trades that closed at/before i (busy_until <= i). + +Implementation +-------------- +1. Build base entries with the winner SkyhookParams. +2. Run backtest_signals -> realized equity path (mark-at-trade-exit, forward-filled). +3. From that equity path compute a per-bar boolean `killed[i]` (causal: peak/DD use eq up to i, + and eq[i] only changes at a trade-exit bar -> the state at the moment we'd open a new entry + at i reflects only past closed trades). +4. Null entries where killed -> re-run. Equity changes (suppressed losers/winners during DD), + so ITERATE to a fixed point (state stabilizes, usually 2-5 iters). +5. Evaluate FULL + HOLD-OUT + fee-sweep + per-year on BOTH assets, marginal-vs-TP01, + combined-curve max-DD, causality (truncated-prefix recompute of the FINAL entries). + +CAUSALITY of the overlay itself +------------------------------- +The base skyhook_entries are already causal (sk.causality). The kill mask at bar i is a function +of equity[0..i], and equity[j] for j<=i only embeds trades whose exit_idx <= i. The mask never +references a future bar. We additionally PROVE it by a truncated-prefix recompute: re-deriving the +final (killed) entries on a data prefix must match the full-run final entries on the overlap. +""" +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.backtest.harness import backtest_signals +from src.strategies.skyhook import SkyhookParams, skyhook_entries + +import altlib as al + +HOLDOUT = sk.HOLDOUT +FEE = sk.FEE_RT +FEE_SWEEP = (0.0, 0.001, 0.002, 0.003) + +# The verified V2 winner from the prior wave. +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) + + +def winner_params() -> SkyhookParams: + return SkyhookParams(**WINNER) + + +# --------------------------------------------------------------------------- +# Causal DD kill-switch overlay +# --------------------------------------------------------------------------- +def killed_mask_from_equity(equity: np.ndarray, dd_kill: float, recover: float) -> np.ndarray: + """Per-bar boolean: True = entries SUPPRESSED at this bar. + + State machine over the realized equity path (hysteresis): + - track running peak. + - if not killed and DD_from_peak > dd_kill -> killed. + - if killed and DD_from_peak <= recover -> un-killed (recovered). + Causal: peak[i] and dd[i] use equity[0..i] only. + """ + n = len(equity) + killed = np.zeros(n, dtype=bool) + peak = equity[0] + state = False + for i in range(n): + if equity[i] > peak: + peak = equity[i] + dd = (peak - equity[i]) / peak if peak > 0 else 0.0 + if state: + if dd <= recover: + state = False + else: + if dd > dd_kill: + state = True + killed[i] = state + return killed + + +def apply_kill(entries: list, killed: np.ndarray) -> list: + out = list(entries) + for i in range(len(out)): + if i < len(killed) and killed[i]: + out[i] = None + return out + + +def ddkill_entries_for_asset(asset: str, p: SkyhookParams, dd_kill: float, recover: float, + fee_rt: float = FEE, max_iter: int = 8): + """Iterate the kill-switch to a fixed point. Returns (final_entries, ltf, n_iters).""" + ltf, htf = sk.frames(asset) + base = skyhook_entries(ltf, htf, p) + cur = base + prev_killcount = -1 + iters = 0 + for it in range(max_iter): + m = backtest_signals(ltf, cur, fee_rt=fee_rt, leverage=1.0, asset=asset, tf="230m") + killed = killed_mask_from_equity(m.equity, dd_kill, recover) + nxt = apply_kill(base, killed) # always re-derive from BASE so a recovered state re-enables entries + iters = it + 1 + kc = int(killed.sum()) + # fixed point: same set of nulled entries as before + same = all((a is None) == (b is None) for a, b in zip(nxt, cur)) + cur = nxt + if same and kc == prev_killcount: + break + prev_killcount = kc + return cur, ltf, iters + + +def _split(eq: np.ndarray, idx: pd.DatetimeIndex, mask: np.ndarray) -> dict: + e = eq[mask] + if len(e) < 5: + return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e))) + r = np.diff(e) / e[:-1] + r = r[np.isfinite(r)] + ix = idx[mask] + dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400 + bpy = 86400 * 365.25 / dt + sharpe = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 + pk = np.maximum.accumulate(e) + dd = float(np.max((pk - e) / pk)) + return dict(sharpe=round(sharpe, 3), ret=round(float(e[-1] / e[0] - 1), 4), + maxdd=round(dd, 4), n=int(len(e))) + + +def study_ddkill(name: str, p: SkyhookParams, dd_kill: float, recover: float): + per_asset = {} + fee_ok_all = True + entries_by_asset = {} + for a in ("BTC", "ETH"): + ent, ltf, iters = ddkill_entries_for_asset(a, p, dd_kill, recover) + entries_by_asset[a] = (ent, ltf) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = _split(eq, idx, hmask) + sweep = {} + for f in FEE_SWEEP: + mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m") + sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3) + fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0) + per_asset[a] = dict(full=full, hold=hold, + yearly={int(y): round(v, 4) for y, v in m.yearly.items()}, + fee_sweep=sweep, iters=iters) + mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset) + mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset) + mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset) + mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset) + grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \ + ("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL") + print(f"\n=== {name} (dd_kill={dd_kill:.0%} recover={recover:.0%}) -> {grade} " + f"(minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})") + for a in per_asset: + pa = per_asset[a] + yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items()) + print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% " + f"DD={pa['full']['maxdd']*100:.0f}% n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}% " + f"| HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}% " + f"[iters={pa['iters']}]") + print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items())) + print(f" yr: {yr}") + return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all, + per_asset=per_asset, entries_by_asset=entries_by_asset, + dd_kill=dd_kill, recover=recover) + + +def marginal_ddkill(p: SkyhookParams, dd_kill: float, recover: float): + def daily(a): + ent, ltf, _ = ddkill_entries_for_asset(a, p, dd_kill, recover) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + return s.resample("1D").last().ffill().pct_change().dropna() + sb, se = daily("BTC"), daily("ETH") + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + cand = 0.5 * J["BTC"] + 0.5 * J["ETH"] + return al.marginal_vs_tp01(cand) + + +def combined_curve_maxdd(res: dict) -> float: + """Max-DD of the COMBINED 50/50 BTC+ETH bar-level equity (single standalone curve).""" + curves = [] + for a in ("BTC", "ETH"): + ent, ltf = res["entries_by_asset"][a] + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + curves.append(s.resample("1D").last().ffill().pct_change().fillna(0.0)) + J = pd.concat({"BTC": curves[0], "ETH": curves[1]}, axis=1, join="inner").fillna(0.0) + r = 0.5 * J["BTC"] + 0.5 * J["ETH"] + eq = (1.0 + r).cumprod().values + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) + + +# --------------------------------------------------------------------------- +# Causality of the FINAL (killed) entries via truncated-prefix recompute. +# --------------------------------------------------------------------------- +def causality_ddkill(p: SkyhookParams, dd_kill: float, recover: float, asset: str = "BTC", + tail: int = 200) -> dict: + """Re-derive final killed entries on a data PREFIX; they must match the full-run final + entries on the overlap tail. Proves the kill mask uses no future bar.""" + full_ent, ltf_full = (lambda r: r[:2])(ddkill_entries_for_asset(asset, p, dd_kill, recover)) + n = len(ltf_full) + ltf, htf = sk.frames(asset) + bad = 0; checked = 0 + for frac in (0.80, 0.92): + cut = int(n * frac) + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + ltf_sub = ltf.iloc[:cut].reset_index(drop=True) + htf_sub = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + # re-run the whole kill iteration on the prefix + base_sub = skyhook_entries(ltf_sub, htf_sub, p) + cur = base_sub + for _ in range(8): + m = backtest_signals(ltf_sub, cur, fee_rt=FEE, leverage=1.0, asset=asset, tf="230m") + killed = killed_mask_from_equity(m.equity, dd_kill, recover) + nxt = apply_kill(base_sub, killed) + same = all((x is None) == (y is None) for x, y in zip(nxt, cur)) + cur = nxt + if same: + break + for i in range(max(0, cut - tail), cut): + checked += 1 + aE, bE = full_ent[i], cur[i] + if (aE is None) != (bE is None): + bad += 1 + elif aE is not None and (aE["dir"] != bE["dir"] + or abs(aE["sl"] - bE["sl"]) > 1e-6 + or abs(aE["tp"] - bE["tp"]) > 1e-6): + bad += 1 + return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) + + +def earns_slot_of(res: dict, mg: dict) -> bool: + return (res["grade"] != "FAIL" + and mg.get("marginal_verdict") == "ADDS" + and bool(mg.get("robust_oos")) + and not bool(mg.get("is_hedge"))) + + +def beats_winner(res: dict, mg: dict, max_dd: float, earns: bool) -> bool: + w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold") + return bool(earns and max_dd < 0.30 + and (w25 is not None and w25 >= 0.55) + and res["minHold"] >= 0.65) + + +if __name__ == "__main__": + p = winner_params() + + print("########## BASELINE (winner, no kill) for reference ##########") + base_rep = sk.study("WINNER (no kill)", p) + print(sk.fmt(base_rep)) + base_dd = max(base_rep["per_asset"][a]["full"]["maxdd"] for a in base_rep["per_asset"]) + print(f" winner standalone maxDD (per-asset max) = {base_dd*100:.0f}%") + + # Grid of (dd_kill, recover). recover < dd_kill (hysteresis: re-enable once DD shrinks back). + grid = [ + (0.15, 0.10), + (0.15, 0.12), + (0.18, 0.12), + (0.18, 0.15), + (0.20, 0.15), + (0.22, 0.16), + (0.25, 0.18), + (0.30, 0.22), + ] + + results = [] + for dd_kill, recover in grid: + res = study_ddkill(f"DDKILL", p, dd_kill, recover) + results.append(res) + + print("\n\n########## MARGINAL + combined-DD + earns_slot ##########") + summary = [] + for res in results: + mg = marginal_ddkill(p, res["dd_kill"], res["recover"]) + cdd = combined_curve_maxdd(res) + per_asset_dd = res["maxDD"] + # standalone max_dd per the brief = max(full.maxdd over BTC & ETH) for the overlay too + max_dd = per_asset_dd + earns = earns_slot_of(res, mg) + bw = beats_winner(res, mg, max_dd, earns) + w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold") + w50 = mg.get("blends", {}).get("w50", {}) + summary.append(dict(dd_kill=res["dd_kill"], recover=res["recover"], grade=res["grade"], + minFull=res["minFull"], minHold=res["minHold"], minTr=res["minTr"], + per_asset_dd=per_asset_dd, combined_dd=cdd, max_dd=max_dd, + corr_full=mg.get("corr_full"), verdict=mg.get("marginal_verdict"), + insample=mg.get("has_insample_edge"), hedge=mg.get("is_hedge"), + robust=mg.get("robust_oos"), multicut=mg.get("multicut_persistent"), + cleanYr=mg.get("clean_year_uplift"), w25=w25, w50=w50, + fee_ok=res["fee_ok"], earns=earns, beats=bw, mg=mg, res=res)) + print(f"[dd_kill={res['dd_kill']:.0%} recover={res['recover']:.0%}] grade={res['grade']} " + f"minFull={res['minFull']:+.2f} minHold={res['minHold']:+.2f} " + f"perAssetDD={per_asset_dd*100:.0f}% combinedDD={cdd*100:.0f}% " + f"| corr={mg.get('corr_full')} verdict={mg.get('marginal_verdict')} " + f"insample={mg.get('has_insample_edge')} hedge={mg.get('is_hedge')} " + f"robust={mg.get('robust_oos')} multicut={mg.get('multicut_persistent')} " + f"cleanYr={mg.get('clean_year_uplift')} w25uplift={w25} " + f"| earns={earns} BEATS={bw}") + + # Pick best honestly: prefer beats_winner; then earns_slot AND a healthy hold-out floor + # (>=0.65) so we never pick a DD win that kills the hold-out; then lowest per-asset DD; + # then highest minHold. + def rank(s): + healthy = bool(s["earns"]) and (s["minHold"] or -9) >= 0.65 + return (not s["beats"], not healthy, not s["earns"], s["max_dd"], -(s["minHold"] or -9)) + summary.sort(key=rank) + best = summary[0] + res = best["res"]; mg = best["mg"] + + # Causality of the FINAL killed entries on the best config, both assets. + cz_btc = causality_ddkill(p, best["dd_kill"], best["recover"], "BTC") + cz_eth = causality_ddkill(p, best["dd_kill"], best["recover"], "ETH") + cz_ok = cz_btc["ok"] and cz_eth["ok"] + + print("\n\n################## BEST CONFIG ##################") + print(f"config: WINNER + DDKILL(dd_kill={best['dd_kill']:.0%}, recover={best['recover']:.0%})") + print(f" minFull = {best['minFull']:+.3f}") + print(f" minHold = {best['minHold']:+.3f}") + print(f" per-asset maxDD= {best['per_asset_dd']*100:.1f}% (max over BTCÐ full.maxdd)") + print(f" combined maxDD= {best['combined_dd']*100:.1f}% (50/50 daily curve)") + print(f" n_trades_min = {best['minTr']}") + print(f" fee@0.30% = {best['fee_ok']}") + print(f" causality = BTC {cz_btc} | ETH {cz_eth} -> ok={cz_ok}") + print(f" --- MARGINAL vs TP01 ---") + print(f" corr_full = {mg.get('corr_full')}") + print(f" corr_hold = {mg.get('corr_hold')}") + print(f" marginal_verdict = {mg.get('marginal_verdict')}") + print(f" has_insample_edge = {mg.get('has_insample_edge')}") + print(f" is_hedge = {mg.get('is_hedge')}") + print(f" robust_oos = {mg.get('robust_oos')}") + print(f" multicut_persistent = {mg.get('multicut_persistent')}") + print(f" clean_year_uplift = {mg.get('clean_year_uplift')}") + print(f" jackknife_min_uplift= {mg.get('jackknife_min_uplift')}") + print(f" cand_insample_sharpe= {mg.get('cand_insample_sharpe')}") + print(f" blends.w25 = {mg.get('blends', {}).get('w25')}") + print(f" blends.w50 = {mg.get('blends', {}).get('w50')}") + earns = best["earns"] + print(f" earns_slot = {earns}") + print(f" BEATS_WINNER = {best['beats']}") + + # Emit a compact machine-readable line for the orchestrator. + import json + w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold") + out = dict( + family="ddkill_entries", + best_config=dict(base=WINNER, dd_kill=best["dd_kill"], recover=best["recover"]), + ran_ok=True, grade=res["grade"], + min_full_sharpe=round(float(best["minFull"]), 3), + min_hold_sharpe=round(float(best["minHold"]), 3), + max_dd=round(float(best["max_dd"]), 4), + combined_dd=round(float(best["combined_dd"]), 4), + n_trades_min=int(best["minTr"]), + fee_survives_030=bool(best["fee_ok"]), + causality_ok=bool(cz_ok), + marginal_verdict=mg.get("marginal_verdict"), + has_insample_edge=bool(mg.get("has_insample_edge")), + is_hedge=bool(mg.get("is_hedge")), + robust_oos=bool(mg.get("robust_oos")), + multicut_persistent=bool(mg.get("multicut_persistent")), + clean_year_uplift=mg.get("clean_year_uplift"), + corr_full=mg.get("corr_full"), + blend_w25_uplift_hold=w25, + earns_slot=bool(earns), + beats_winner=bool(best["beats"]), + ) + print("\nRESULT_JSON " + json.dumps(out, default=str)) diff --git a/scripts/research/skyhook/runs/SKH2_DUALTF_PTN.py b/scripts/research/skyhook/runs/SKH2_DUALTF_PTN.py new file mode 100644 index 0000000..28bfd5a --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_DUALTF_PTN.py @@ -0,0 +1,399 @@ +"""SKH2_DUALTF_PTN — LTF (230m) CONFIRMATION of the HTF (690m) breakout at entry. + +FAMILY: DUALTF_PTN. Hypothesis (DD-cut): the V2 winner enters on a fresh HTF Donchian +breakout regardless of where the LTF exec-frame is. If we ALSO require the LTF to confirm +the breakout at the entry bar (LTF close[i] above its own EMA(n) for longs / below for +shorts, or LTF short-term momentum agrees), we avoid entering against a freshly-turned LTF. +Fewer "fight the exec-frame" fills -> fewer immediate stop-outs -> lower standalone maxDD, +ideally without gutting the hold-out edge. + +BASELINE (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). + minFull +0.83, minHold +0.81, maxDD BTC 34% / ETH 31% (THE PROBLEM), marginal ADDS. + +WHAT THIS SCRIPT DOES (all leak-free): + * Reuse S.htf_features (V2 composer, Chande regime) -> comp_long/comp_short on HTF close. + * merge backward to LTF (S.merge_htf_to_ltf) -> causal HTF signal at each LTF bar. + * Compute LTF confirmation features at close[i]: EMA(n) of LTF close, and an LTF + momentum (close[i] vs close[i-mom]). All strictly causal (no shift into the future). + * AND the HTF composer with the LTF confirmation: long only if comp_long & ltf_up; + short only if comp_short & ltf_dn. (ltf_up/ltf_dn defined by chosen confirm mode.) + * Same entry/exit machinery as V2 (sl/tp ATR multiples, asymmetric max_bars, 1/day). + +CAUSALITY: every LTF feature uses ltf data with index <= i. EMA via ewm(adjust=False) is a +pure causal recursion; momentum uses close[i] and close[i-mom]. We prove it with a +truncated-prefix recompute (same protocol as sk.causality) on our custom entries. +""" +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.backtest.harness import backtest_signals +from src.strategies import skyhook as S +from src.strategies.skyhook import SkyhookParams + +HOLDOUT = sk.HOLDOUT +FEE = sk.FEE_RT + +# V2 winner params (the baseline to beat). LTF confirmation rides on top of these. +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) + + +def winner_params(**kw): + base = dict(WINNER) + base.update(kw) + return SkyhookParams(**base) + + +# --------------------------------------------------------------------------- +# Causal LTF confirmation features (computed on the 230m exec frame, at close[i]). +# --------------------------------------------------------------------------- +def ltf_confirm(ltf_close: np.ndarray, *, ema_n: int, mom_n: int) -> tuple[np.ndarray, np.ndarray]: + """Return (ltf_up, ltf_dn) boolean masks per LTF bar, strictly causal. + ltf_up := close[i] > EMA_n(close)[i] AND close[i] > close[i-mom_n] (momentum agrees) + ltf_dn := close[i] < EMA_n(close)[i] AND close[i] < close[i-mom_n] + EMA via ewm(adjust=False): a causal recursion (uses only data <= i).""" + c = pd.Series(np.asarray(ltf_close, float)) + ema = c.ewm(span=ema_n, adjust=False).mean().values + cc = c.values + mom_up = np.zeros(len(cc), dtype=bool) + mom_dn = np.zeros(len(cc), dtype=bool) + if mom_n > 0: + mom_up[mom_n:] = cc[mom_n:] > cc[:-mom_n] + mom_dn[mom_n:] = cc[mom_n:] < cc[:-mom_n] + else: + mom_up[:] = True + mom_dn[:] = True + up = (cc > ema) & mom_up + dn = (cc < ema) & mom_dn + return up, dn + + +def ltf_confirm_modes(ltf_close: np.ndarray, *, ema_n: int, mom_n: int, mode: str, + slope_n: int = 0): + """Causal LTF confirmation masks (ltf_up, ltf_dn). All features use data <= i. + Components: + ema_up := close[i] > EMA_n(close)[i] + mom_up := close[i] > close[i-mom_n] (sustained move over mom_n bars) + slope_up:= EMA_n(close)[i] > EMA_n(close)[i-slope_n] (LTF trend is rising) if slope_n>0 + Modes: + 'ema' -> ema_up + 'mom' -> mom_up + 'both' -> ema_up & mom_up + 'or' -> ema_up | mom_up + 'slope' -> slope_up only (EMA itself rising/falling) + 'ema_slope' -> ema_up & slope_up (above a rising EMA = real LTF uptrend, strict) + 'all' -> ema_up & mom_up & slope_up (strictest) + """ + c = pd.Series(np.asarray(ltf_close, float)) + ema = c.ewm(span=ema_n, adjust=False).mean().values + cc = c.values + n = len(cc) + ema_up = cc > ema + ema_dn = cc < ema + mom_up = np.zeros(n, dtype=bool) + mom_dn = np.zeros(n, dtype=bool) + if mom_n > 0: + mom_up[mom_n:] = cc[mom_n:] > cc[:-mom_n] + mom_dn[mom_n:] = cc[mom_n:] < cc[:-mom_n] + else: + mom_up[:] = True + mom_dn[:] = True + slope_up = np.zeros(n, dtype=bool) + slope_dn = np.zeros(n, dtype=bool) + if slope_n > 0: + slope_up[slope_n:] = ema[slope_n:] > ema[:-slope_n] + slope_dn[slope_n:] = ema[slope_n:] < ema[:-slope_n] + else: + slope_up[:] = True + slope_dn[:] = True + if mode == "ema": + return ema_up, ema_dn + if mode == "mom": + return mom_up, mom_dn + if mode == "or": + return (ema_up | mom_up), (ema_dn | mom_dn) + if mode == "slope": + return slope_up, slope_dn + if mode == "ema_slope": + return (ema_up & slope_up), (ema_dn & slope_dn) + if mode == "all": + return (ema_up & mom_up & slope_up), (ema_dn & mom_dn & slope_dn) + # default 'both' + return (ema_up & mom_up), (ema_dn & mom_dn) + + +def ltf_not_overextended(ltf_close: np.ndarray, ltf_atr: np.ndarray, *, + ema_n: int, max_ext_atr: float): + """REJECT (return False) when the LTF is already overextended from its EMA at entry: + a long-breakout fired when close[i] is already > ema + max_ext_atr*ATR_LTF[i] is a LATE + fill (mean-reversion-prone, big-stop risk). Confirmation = NOT overextended. + ltf_up := (close - ema) <= max_ext_atr*ATR (still room to run, not blown off) + ltf_dn := (ema - close) <= max_ext_atr*ATR + All causal: ema, ATR, close all at i.""" + c = pd.Series(np.asarray(ltf_close, float)) + ema = c.ewm(span=ema_n, adjust=False).mean().values + cc = c.values + a = np.asarray(ltf_atr, float) + a = np.where(np.isfinite(a) & (a > 0), a, np.nan) + ext = (cc - ema) / a + # long: not too far ABOVE ema ; short: not too far BELOW ema + up = np.where(np.isfinite(ext), ext <= max_ext_atr, False) + dn = np.where(np.isfinite(ext), (-ext) <= max_ext_atr, False) + return up, dn + + +# --------------------------------------------------------------------------- +# Custom entries: V2 HTF composer AND LTF confirmation. +# --------------------------------------------------------------------------- +def dualtf_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams, + *, ema_n: int, mom_n: int, mode: str, slope_n: int = 0, + max_ext_atr: float = 0.0) -> list: + feat = S.htf_features(htf, p) # V2 composer (Chande regime + Donchian) + m = S.merge_htf_to_ltf(ltf, feat) # causal backward merge + + c = m["close"].values.astype(float) + a = S.atr(m, p.ltf_atr_win) + comp_long = np.nan_to_num(m["comp_long"].values).astype(bool) + comp_short = np.nan_to_num(m["comp_short"].values).astype(bool) + + if mode == "notext": + ltf_up, ltf_dn = ltf_not_overextended(c, a, ema_n=ema_n, max_ext_atr=max_ext_atr) + else: + ltf_up, ltf_dn = ltf_confirm_modes(c, ema_n=ema_n, mom_n=mom_n, mode=mode, slope_n=slope_n) + comp_long = comp_long & ltf_up + comp_short = comp_short & ltf_dn + + days = pd.to_datetime(m["datetime"]).dt.floor("D").values + entries: list = [None] * len(m) + count_today: dict = {} + for i in range(len(m)): + if not np.isfinite(a[i]) or a[i] <= 0: + continue + day = days[i] + if count_today.get(day, 0) >= p.max_per_day: + continue + if comp_long[i]: + direction, mb = 1, p.uscitalong + elif comp_short[i]: + direction, mb = -1, p.uscitashort + else: + continue + if p.exit_mode == "atr": + sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i] + else: + sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i] + if direction == 1: + sl, tp = c[i] - sl_off, c[i] + tp_off + else: + sl, tp = c[i] + sl_off, c[i] - tp_off + entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)} + count_today[day] = count_today.get(day, 0) + 1 + return entries + + +# --------------------------------------------------------------------------- +# Eval helpers (FULL + HOLD-OUT + fee sweep + per-year, both assets) — mirrors sk.study. +# --------------------------------------------------------------------------- +def _split(eq, idx, mask): + e = eq[mask] + if len(e) < 5: + return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e))) + r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)] + ix = idx[mask] + dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400 + bpy = 86400 * 365.25 / dt + sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 + pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk)) + return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e))) + + +def study_dualtf(name, p, confirm): + per_asset = {} + fee_ok_all = True + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = dualtf_entries(ltf, htf, p, **confirm) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = _split(eq, idx, hmask) + sweep = {} + for f in (0.0, 0.001, 0.002, 0.003): + mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m") + sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3) + fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0) + per_asset[a] = dict(full=full, hold=hold, fee_sweep=sweep, + yearly={int(y): round(v, 4) for y, v in m.yearly.items()}) + mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset) + mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset) + mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset) + mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset) + grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \ + ("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL") + print(f"\n=== {name} -> {grade} (minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})") + for a in per_asset: + pa = per_asset[a] + yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items()) + print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% DD={pa['full']['maxdd']*100:.0f}%" + f" n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}% | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}%") + print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items())) + print(f" yr: {yr}") + return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all, per_asset=per_asset) + + +def marginal_dualtf(p, confirm): + import altlib as al + def daily(a): + ltf, htf = sk.frames(a) + ent = dualtf_entries(ltf, htf, p, **confirm) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + return s.resample("1D").last().ffill().pct_change().dropna() + sb, se = daily("BTC"), daily("ETH") + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + cand = 0.5 * J["BTC"] + 0.5 * J["ETH"] + return al.marginal_vs_tp01(cand) + + +def check_causality(p, confirm, asset="BTC", tail=150): + ltf, htf = sk.frames(asset) + full = dualtf_entries(ltf, htf, p, **confirm) + n = len(ltf); bad = 0; checked = 0 + for frac in (0.80, 0.92): + cut = int(n * frac) + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + sub = dualtf_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, **confirm) + for i in range(max(0, cut - tail), cut): + checked += 1 + a, b = full[i], sub[i] + if (a is None) != (b is None): + bad += 1 + elif a is not None and (a["dir"] != b["dir"] + or abs(a["sl"] - b["sl"]) > 1e-6 + or abs(a["tp"] - b["tp"]) > 1e-6): + bad += 1 + return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) + + +if __name__ == "__main__": + print("########## SKH2_DUALTF_PTN: LTF confirmation of HTF breakout ##########") + + # Reference: V2 winner WITHOUT LTF confirmation (mode 'none' via wide-open masks). + p = winner_params() + + # --- Reference (no LTF confirm) using sk.run_asset directly --- + print("\n--- V2 WINNER reference (no LTF confirm) ---") + refF, refH, refDD, refTr = [], [], [], [] + for a in ("BTC", "ETH"): + r = sk.run_asset(a, p, FEE) + refF.append(r["full"]["sharpe"]); refH.append(r["holdout"]["sharpe"]) + refDD.append(r["full"]["maxdd"]); refTr.append(r["full"]["n_trades"]) + print(f" {a}: FULL Sh={r['full']['sharpe']:+.2f} DD={r['full']['maxdd']*100:.0f}% n={r['full']['n_trades']}" + f" | HOLD Sh={r['holdout']['sharpe']:+.2f}") + print(f" REF minFull={min(refF):+.2f} minHold={min(refH):+.2f} maxDD={max(refDD)*100:.0f}% minTr={min(refTr)}") + + # --- Sweep LTF confirmation configs --- + # ema_n / mom_n on 230m bars. ~6.26 bars/day. EMA 10~1.6d, 20~3.2d. mom small (1-6 bars). + # Directional confirms are near-redundant with a fresh breakout (barely filter). + # The real DD lever in this family: REJECT OVEREXTENDED LTF fills (late, blow-off, + # mean-reversion-prone, big-stop). max_ext_atr = max allowed (close-ema)/ATR_LTF at entry. + configs = { + # reference directional confirm (keeps ~all trades) + "mom_only_m3": dict(ema_n=20, mom_n=3, mode="mom"), + # NOT-OVEREXTENDED gate: tighter max_ext -> fewer late fills -> aim lower DD + "notext_e20_x4": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=4.0), + "notext_e20_x3": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=3.0), + "notext_e20_x2": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=2.0), + "notext_e20_x1_5": dict(ema_n=20, mom_n=0, mode="notext", max_ext_atr=1.5), + "notext_e30_x3": dict(ema_n=30, mom_n=0, mode="notext", max_ext_atr=3.0), + "notext_e30_x2": dict(ema_n=30, mom_n=0, mode="notext", max_ext_atr=2.0), + "notext_e10_x2": dict(ema_n=10, mom_n=0, mode="notext", max_ext_atr=2.0), + "notext_e10_x1_5": dict(ema_n=10, mom_n=0, mode="notext", max_ext_atr=1.5), + "notext_e30_x1_5": dict(ema_n=30, mom_n=0, mode="notext", max_ext_atr=1.5), + } + + results = {} + for tag, cfg in configs.items(): + r = study_dualtf(f"DUALTF_{tag}", p, cfg) + results[tag] = (cfg, r) + + # --- Pick best: priority (1) DD<30, (2) earns_slot, (3) minHold high --- + print("\n\n##### MARGINAL vs TP01 (configs with minTr>=20) #####") + scored = [] + for tag, (cfg, r) in results.items(): + if r["minTr"] < 20: + print(f"[{tag}] minTr={r['minTr']} <20 -> skip marginal") + continue + mg = marginal_dualtf(p, cfg) + verdict = mg.get("marginal_verdict") + robust = bool(mg.get("robust_oos")) + hedge = bool(mg.get("is_hedge")) + earns = (r["grade"] != "FAIL") and (verdict == "ADDS") and robust and (not hedge) + w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold") + beats = earns and (r["maxDD"] < 0.30) and (w25 is not None and w25 >= 0.55) and (r["minHold"] >= 0.65) + scored.append((tag, cfg, r, mg, earns, beats)) + print(f"[{tag}] grade={r['grade']} minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f} maxDD={r['maxDD']*100:.0f}%" + f" | corr_full={mg.get('corr_full')} verdict={verdict} insample={mg.get('has_insample_edge')}" + f" hedge={hedge} robust={robust} w25uplift={w25} earns_slot={earns} BEATS={beats}") + + if not scored: + print("\nNo config with enough trades.") + sys.exit(0) + + # Rank: beats_winner first, then DD<30 & earns, then by minHold, then by lowest DD. + def rank_key(item): + tag, cfg, r, mg, earns, beats = item + w25 = mg.get("blends", {}).get("w25", {}).get("uplift_hold") or -9 + return (beats, (r["maxDD"] < 0.30 and earns), earns, r["minHold"], -r["maxDD"]) + scored.sort(key=rank_key, reverse=True) + best_tag, best_cfg, best_r, best_mg, best_earns, best_beats = scored[0] + + # --- Causality on best --- + cb = check_causality(p, best_cfg, "BTC") + ce = check_causality(p, best_cfg, "ETH") + caus_ok = cb["ok"] and ce["ok"] + + w25 = best_mg.get("blends", {}).get("w25", {}).get("uplift_hold") + w50 = best_mg.get("blends", {}).get("w50", {}) + fee030_min = min(best_r["per_asset"][a]["fee_sweep"]["0.30%"] for a in ("BTC", "ETH")) + + print("\n\n##################### BEST CONFIG #####################") + print(f"BEST = DUALTF_{best_tag} cfg={best_cfg}") + print(f" params = {WINNER}") + print(f" grade={best_r['grade']} minFull={best_r['minFull']:+.2f} minHold={best_r['minHold']:+.2f}" + f" maxDD={best_r['maxDD']*100:.1f}% minTr={best_r['minTr']}") + print(f" fee@0.30% (min asset FULL Sh) = {fee030_min:+.3f} feeOK={best_r['fee_ok']}") + print(f" causality: BTC={cb} ETH={ce} -> OK={caus_ok}") + print(f" marginal: corr_full={best_mg.get('corr_full')} corr_hold={best_mg.get('corr_hold')}" + f" verdict={best_mg.get('marginal_verdict')}") + print(f" has_insample_edge={best_mg.get('has_insample_edge')} is_hedge={best_mg.get('is_hedge')}" + f" robust_oos={best_mg.get('robust_oos')} multicut_persistent={best_mg.get('multicut_persistent')}") + print(f" clean_year_uplift={best_mg.get('clean_year_uplift')}" + f" jackknife_min_uplift={best_mg.get('jackknife_min_uplift')}" + f" cand_insample_sharpe={best_mg.get('cand_insample_sharpe')}") + print(f" blend w25 uplift_hold={w25} w50={w50}") + print(f" earns_slot={best_earns} BEATS_WINNER={best_beats}") + + # Emit a compact machine-readable line for the harness. + import json + out = dict(family="DUALTF_PTN", best_tag=best_tag, best_cfg=best_cfg, winner_params=WINNER, + grade=best_r["grade"], minFull=best_r["minFull"], minHold=best_r["minHold"], + maxDD=best_r["maxDD"], minTr=best_r["minTr"], fee030_min=fee030_min, + causality_ok=caus_ok, marginal_verdict=best_mg.get("marginal_verdict"), + corr_full=best_mg.get("corr_full"), has_insample_edge=best_mg.get("has_insample_edge"), + is_hedge=best_mg.get("is_hedge"), robust_oos=best_mg.get("robust_oos"), + multicut_persistent=best_mg.get("multicut_persistent"), + clean_year_uplift=best_mg.get("clean_year_uplift"), w25_uplift_hold=w25, w50=w50, + earns_slot=best_earns, beats_winner=best_beats) + print("\nJSON " + json.dumps(out, default=str)) diff --git a/scripts/research/skyhook/runs/SKH2_ENS_PARAM.py b/scripts/research/skyhook/runs/SKH2_ENS_PARAM.py new file mode 100644 index 0000000..0c3ef9a --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_ENS_PARAM.py @@ -0,0 +1,285 @@ +"""SKH2_ENS_PARAM — within-sleeve PARAM ENSEMBLE for Skyhook DD reduction. + +Family: equal-weight the DAILY returns of K diverse skyhook param sets (incl. the V2 winner), +varying ptn_n {25,45,90}, exits, sl/tp. Diversification across configs smooths equity and cuts +standalone DD without killing hold-out. We: + * build each config's per-asset 230m equity (sk.run_asset) -> daily returns, + * equal-weight average the configs' daily returns PER ASSET -> ensemble per-asset equity -> + standalone DD (max over BTC/ETH) and per-asset/year/full/hold Sharpe via the SAME _split logic, + * fee sweep: re-run each config at fee f, average daily, recompute Sharpe (fee_ok = Sharpe>0 @0.30% RT), + * causality: every member is a pure SkyhookParams variant -> sk.causality on each (must be ok), + * marginal: feed the 50/50 ensemble daily series to altlib.marginal_vs_tp01. + +Standalone max_dd for the ensemble = max-DD of the COMBINED (averaged) per-asset equity curve. +All causal/leak-free: ensemble is a linear combo of leak-free member equities; no future data used. +""" +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 +CERTIFIED = ("BTC", "ETH") +ANN = np.sqrt(365.25) + +# The verified V2 winner (must be a member of every ensemble). +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 _sharpe(r: np.ndarray) -> float: + r = r[np.isfinite(r)] + return float(np.mean(r) / np.std(r) * ANN) if len(r) > 2 and np.std(r) > 0 else 0.0 + + +def _dd_from_eq(eq: np.ndarray) -> float: + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + + +# --------------------------------------------------------------------------- +# Per-config DAILY equity-return series per asset, cached by (config-id, asset, fee). +# We use sk.run_asset to get the leak-free 230m equity, then resample to daily LAST and +# take pct_change -> daily returns. Aligning all members on the same daily index lets us +# equal-weight-average their daily returns (an equal-capital rebalanced ensemble). +# --------------------------------------------------------------------------- +_DAILY_CACHE: dict = {} +_NTR_CACHE: dict = {} + + +def _config_daily(p: SkyhookParams, asset: str, fee: float) -> pd.Series: + key = (id(p), asset, fee) + if key in _DAILY_CACHE: + return _DAILY_CACHE[key] + r = sk.run_asset(asset, p, fee) + s = pd.Series(r["_eq"], index=r["_idx"]) + daily = s.resample("1D").last().ffill().pct_change().dropna() + _DAILY_CACHE[key] = daily + _NTR_CACHE[(id(p), asset)] = r["full"]["n_trades"] + return daily + + +def _ensemble_daily_asset(members, asset: str, fee: float) -> pd.Series: + """Equal-weight average of members' daily returns for one asset (common dates).""" + cols = {f"m{i}": _config_daily(p, asset, fee) for i, p in enumerate(members)} + J = pd.concat(cols, axis=1, join="inner").fillna(0.0) + return J.mean(axis=1) + + +def study_ensemble(name: str, members) -> dict: + """FULL+HOLD+fee-sweep+per-year on BOTH assets for the equal-weight param ensemble. + Standalone DD = max-DD of the averaged per-asset equity curve.""" + per_asset = {} + fee_ok_all = True + for a in CERTIFIED: + ens = _ensemble_daily_asset(members, a, FEE) + eq = np.cumprod(1.0 + ens.values) + idx = ens.index + full_sh = _sharpe(ens.values) + full_dd = _dd_from_eq(eq) + full_ret = float(eq[-1] / eq[0] - 1) if len(eq) else 0.0 + hmask = idx >= HOLDOUT + rh = ens.values[hmask] + eqh = np.cumprod(1.0 + rh) if rh.size else np.array([1.0]) + hold_sh = _sharpe(rh) + hold_ret = float(eqh[-1] / eqh[0] - 1) if eqh.size else 0.0 + hold_dd = _dd_from_eq(eqh) + # per-year Sharpe-equivalent return + yearly = {} + for y in sorted(set(idx.year)): + ry = ens.values[idx.year == y] + eqy = np.cumprod(1.0 + ry) if ry.size else np.array([1.0]) + yearly[int(y)] = float(eqy[-1] - 1.0) if eqy.size else 0.0 + # fee sweep + sweep = {} + for f in (0.0, 0.001, 0.002, 0.003): + ensf = _ensemble_daily_asset(members, a, f) + sweep[f"{f*100:.2f}%"] = round(_sharpe(ensf.values), 3) + fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0) + # n_trades = sum across members (the ensemble trades all of them) + ntr = sum(_NTR_CACHE.get((id(p), a), 0) for p in members) + per_asset[a] = dict( + full=dict(sharpe=round(full_sh, 3), ret=round(full_ret, 4), maxdd=round(full_dd, 4), + n_trades=int(ntr)), + hold=dict(sharpe=round(hold_sh, 3), ret=round(hold_ret, 4), maxdd=round(hold_dd, 4)), + yearly=yearly, fee_sweep=sweep) + mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset) + mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset) + mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset) + mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset) + grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \ + ("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL") + print(f"\n=== {name} -> {grade} (minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})") + for a in per_asset: + pa = per_asset[a] + yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items()) + print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% DD={pa['full']['maxdd']*100:.0f}%" + f" n={pa['full']['n_trades']} | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}% DD={pa['hold']['maxdd']*100:.0f}%") + print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items())) + print(f" yr: {yr}") + return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all, + per_asset=per_asset) + + +def ensemble_5050_daily(members, fee: float = FEE) -> pd.Series: + """50/50 BTC+ETH ensemble daily series (same convention as altlib baseline) for marginal.""" + sb = _ensemble_daily_asset(members, "BTC", fee) + se = _ensemble_daily_asset(members, "ETH", fee) + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + return 0.5 * J["BTC"] + 0.5 * J["ETH"] + + +def marginal_ensemble(members) -> dict: + return al.marginal_vs_tp01(ensemble_5050_daily(members)) + + +def report(tag, members, names): + r = study_ensemble(tag, members) + caus = {} + for i, p in enumerate(members): + cb = sk.causality(p, "BTC") + ce = sk.causality(p, "ETH") + caus[names[i]] = (cb["ok"], ce["ok"]) + caus_ok = all(b and e for b, e in caus.values()) + mg = marginal_ensemble(members) + w25 = mg.get("blends", {}).get("w25", {}) + w50 = mg.get("blends", {}).get("w50", {}) + earns = (r["grade"] != "FAIL" and mg.get("marginal_verdict") == "ADDS" + and mg.get("robust_oos") is True and mg.get("is_hedge") is False) + beats = (earns and r["maxDD"] < 0.30 and (w25.get("uplift_hold") or -9) >= 0.55 + and r["minHold"] >= 0.65) + print(f"\n----- MARGINAL [{tag}] -----") + print(f" members: {names}") + print(f" causality per member (BTC,ETH): {caus} -> all_ok={caus_ok}") + print(f" corr_full={mg.get('corr_full')} corr_hold={mg.get('corr_hold')} verdict={mg.get('marginal_verdict')}") + print(f" has_insample_edge={mg.get('has_insample_edge')} cand_insample_sharpe={mg.get('cand_insample_sharpe')}" + f" is_hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')} multicut_persistent={mg.get('multicut_persistent')}") + print(f" clean_year_uplift={mg.get('clean_year_uplift')} jackknife_min_uplift={mg.get('jackknife_min_uplift')}" + f" multicut_uplift={mg.get('multicut_uplift')}") + print(f" w25={w25}") + print(f" w50={w50}") + print(f" => earns_slot={earns} BEATS_WINNER={beats} (DD={r['maxDD']*100:.0f}% minHold={r['minHold']:+.2f} w25_up_hold={w25.get('uplift_hold')})") + return dict(study=r, marginal=mg, caus_ok=caus_ok, earns=earns, beats=beats, w25=w25, w50=w50) + + +if __name__ == "__main__": + # ---- Diverse member pool (all pure SkyhookParams variants, all causal) ---- + # WINNER (ptn_n=45, sl2.5/tp7.0, exits 24/16, vola 35-95, vol_lo 0) + P_WIN = WINNER + # Faster pattern, tighter stop, shorter TP (different turnover/regime sensitivity) + P_FAST = SkyhookParams(ptn_n=25, sl_atr=2.0, tp_atr=5.0, uscitalong=18, uscitashort=12, + vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + # Slow pattern, wider stop, longer TP (smoother, fewer trades) + P_SLOW = SkyhookParams(ptn_n=90, sl_atr=3.0, tp_atr=9.0, uscitalong=30, uscitashort=20, + vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + # Mid pattern, percent exits (structurally different exit mode) + tighter vola band + P_PCT = SkyhookParams(ptn_n=45, exit_mode="pct", sl_pct=0.04, tp_pct=0.10, + uscitalong=24, uscitashort=16, vola_lo=30.0, vola_hi=90.0, vol_lo=0.0) + # Low-vol gate variant: add a vol floor + slightly different vola band (regime diversity) + P_GATE = SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=6.0, uscitalong=24, uscitashort=16, + vola_lo=40.0, vola_hi=95.0, vol_lo=40.0) + # DEFENSIVE members: tight stop cuts losers fast -> shallow per-trade DD (the DD-cutters). + P_TIGHT = SkyhookParams(ptn_n=45, sl_atr=1.5, tp_atr=4.5, uscitalong=18, uscitashort=12, + vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + P_TIGHT2 = SkyhookParams(ptn_n=25, sl_atr=1.3, tp_atr=4.0, uscitalong=14, uscitashort=10, + vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + # Calm-regime gate (sit out high-vola tails) + tight stop -> lowest DD contributor + P_CALM = SkyhookParams(ptn_n=45, sl_atr=1.8, tp_atr=5.0, uscitalong=20, uscitashort=14, + vola_lo=20.0, vola_hi=70.0, vol_lo=0.0) + # Calm variants: narrower / different vola windows -> diverse DD timing among defenders. + P_CALM2 = SkyhookParams(ptn_n=90, sl_atr=1.8, tp_atr=5.5, uscitalong=24, uscitashort=16, + vola_lo=25.0, vola_hi=65.0, vol_lo=0.0) + P_CALM3 = SkyhookParams(ptn_n=45, sl_atr=2.0, tp_atr=6.0, uscitalong=24, uscitashort=16, + vola_lo=15.0, vola_hi=60.0, vol_lo=0.0) + # CALM4: strong hold-out defensive (wider TP like winner but calm band) — uplift booster + P_CALM4 = SkyhookParams(ptn_n=45, sl_atr=2.2, tp_atr=7.0, uscitalong=24, uscitashort=16, + vola_lo=15.0, vola_hi=65.0, vol_lo=0.0) + # CALM5: ptn_n=90 calm + wide TP (smooth, strong) for uplift+DD balance + P_CALM5 = SkyhookParams(ptn_n=90, sl_atr=2.0, tp_atr=7.0, uscitalong=28, uscitashort=18, + vola_lo=15.0, vola_hi=62.0, vol_lo=0.0) + + POOL = {"WIN": P_WIN, "FAST": P_FAST, "SLOW": P_SLOW, "PCT": P_PCT, "GATE": P_GATE, + "TIGHT": P_TIGHT, "TIGHT2": P_TIGHT2, "CALM": P_CALM, + "CALM2": P_CALM2, "CALM3": P_CALM3, "CALM4": P_CALM4, "CALM5": P_CALM5} + + # ---- First: standalone DD of each member (diagnostic) ---- + print("\n===== STANDALONE per-member DD (max over BTC/ETH) =====") + for k, p in POOL.items(): + dds, fhs, hhs = {}, {}, {} + for a in CERTIFIED: + r = sk.run_asset(a, p, FEE) + dds[a] = r["full"]["maxdd"]; fhs[a] = r["full"]["sharpe"]; hhs[a] = r["holdout"]["sharpe"] + print(f" {k:7s}: maxDD={max(dds.values())*100:.0f}% (BTC {dds['BTC']*100:.0f}/ETH {dds['ETH']*100:.0f})" + f" minFull={min(fhs.values()):+.2f} minHold={min(hhs.values()):+.2f}") + + results = {} + + # Smallest K first: K=3, then K=4, K=5 mixes (winner always included). + # Focus on DEFENSIVE-heavy mixes to drive standalone DD below 30%. + mixes = { + # maximize w25 uplift_hold (>=0.55) while keeping DD<30 -> strong-holdout calm members + "K3_WIN_CALM3_CALM4": ["WIN", "CALM3", "CALM4"], + "K3_WIN_CALM4_CALM5": ["WIN", "CALM4", "CALM5"], + "K3_WIN_CALM3_CALM5": ["WIN", "CALM3", "CALM5"], + "K3_WIN_PCT_CALM3": ["WIN", "PCT", "CALM3"], # PCT has hold 1.0 (uplift booster) but DD 43 + "K4_WIN_CALM3_CALM4_CALM5":["WIN", "CALM3", "CALM4", "CALM5"], + "K3_WIN_CALM3_CALM2": ["WIN", "CALM3", "CALM2"], # prior: DD 24, uplift 0.508 + "K3_WIN_CALM_CALM3": ["WIN", "CALM", "CALM3"], # prior: DD 23, uplift 0.493 + "K4_WIN_GATE_CALM3_CALM4": ["WIN", "GATE", "CALM3", "CALM4"], + "K3_WIN_GATE_CALM3": ["WIN", "GATE", "CALM3"], + } + + for tag, keys in mixes.items(): + members = [POOL[k] for k in keys] + print(f"\n########## {tag} members={keys} ##########") + results[tag] = report(tag, members, keys) + + # ---- pick best: prefer BEATS_WINNER, else best by (earns, low DD, hold) ---- + def score(res): + r, w25 = res["study"], res["w25"] + uh = (w25.get("uplift_hold") or -9) + dd_ok = 1 if r["maxDD"] < 0.30 else 0 # goal #1 first + hold_ok = 1 if r["minHold"] >= 0.65 else 0 # goal #2 + return (1 if res["beats"] else 0, 1 if res["earns"] else 0, + dd_ok, hold_ok, uh, -r["maxDD"]) + best_tag = max(results, key=lambda t: score(results[t])) + bres = results[best_tag] + br, bw25, bw50, bmg = bres["study"], bres["w25"], bres["w50"], bres["marginal"] + + print("\n\n##################### BEST ENSEMBLE #####################") + print(f"BEST = {best_tag} members={mixes[best_tag]}") + print(f"grade={br['grade']} minFull={br['minFull']:+.3f} minHold={br['minHold']:+.3f}" + f" max_dd={br['maxDD']:.4f} n_trades_min={br['minTr']} fee_ok(@0.30%)={br['fee_ok']}") + print(f"causality_ok={bres['caus_ok']}") + print(f"marginal: corr_full={bmg.get('corr_full')} verdict={bmg.get('marginal_verdict')}" + f" has_insample_edge={bmg.get('has_insample_edge')} is_hedge={bmg.get('is_hedge')}" + f" robust_oos={bmg.get('robust_oos')} multicut_persistent={bmg.get('multicut_persistent')}" + f" clean_year_uplift={bmg.get('clean_year_uplift')}") + print(f"blend w25 uplift_hold={bw25.get('uplift_hold')} uplift_full={bw25.get('uplift_full')}") + print(f"blend w50 full={bw50.get('full')} hold={bw50.get('hold')} dd={bw50.get('dd')}") + print(f"earns_slot={bres['earns']} BEATS_WINNER={bres['beats']}") + + # Emit a machine-readable line so the agent can lift exact numbers. + import json + print("\nRESULT_JSON " + json.dumps({ + "best_tag": best_tag, "members": mixes[best_tag], + "grade": br["grade"], "minFull": br["minFull"], "minHold": br["minHold"], + "max_dd": br["maxDD"], "n_trades_min": br["minTr"], "fee_ok": br["fee_ok"], + "causality_ok": bres["caus_ok"], + "corr_full": bmg.get("corr_full"), "verdict": bmg.get("marginal_verdict"), + "has_insample_edge": bmg.get("has_insample_edge"), "is_hedge": bmg.get("is_hedge"), + "robust_oos": bmg.get("robust_oos"), "multicut_persistent": bmg.get("multicut_persistent"), + "clean_year_uplift": bmg.get("clean_year_uplift"), + "w25_uplift_hold": bw25.get("uplift_hold"), "w50_full": bw50.get("full"), + "w50_hold": bw50.get("hold"), "w50_dd": bw50.get("dd"), + "earns_slot": bres["earns"], "beats_winner": bres["beats"], + "cand_insample_sharpe": bmg.get("cand_insample_sharpe"), + }, default=str)) diff --git a/scripts/research/skyhook/runs/SKH2_ENS_STRUCT.py b/scripts/research/skyhook/runs/SKH2_ENS_STRUCT.py new file mode 100644 index 0000000..de9402b --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_ENS_STRUCT.py @@ -0,0 +1,375 @@ +"""SKH2_ENS_STRUCT — cross-definition ENSEMBLE [ENS_STRUCT]. + +WAVE GOAL: cut the V2 winner's STANDALONE max-DD below 30% (BTC 34% / ETH 31% is the only +unmet goal) while keeping min-asset HOLD-OUT >= ~0.70 and earns_slot True. + +IDEA: the V2 winner uses ONE regime definition (Chande01 cycle band on ATR + Donchian +breakout). Its drawdowns come from that one signal source firing into the wrong tape. If we +ensemble it with STRUCTURALLY DIFFERENT regime definitions — (B) causal PERCENTILE-RANK regime +(SKH_R_PCTL) and (C) VOLATILITY-EXPANSION regime (SKH_R_EXPAND) — that disagree about WHEN to +trade, their drawdowns are imperfectly correlated. Equal-weighting the three daily-return +streams (per asset, then 50/50 across BTC+ETH) should reduce the COMBINED-equity DD below any +single member's DD, at a modest cost to full Sharpe. + +The ensemble is EQUAL-WEIGHT on DAILY RETURNS (a constant-weight rebalanced book of three +sub-sleeves on the SAME asset). Standalone DD = max-DD of the COMBINED equity curve (per asset, +max over BTC & ETH). Marginal vs TP01 uses the 50/50 BTC+ETH combined daily series. + +All three members are causal/leak-free (winner via sk.causality; PCTL/EXPAND via their own +truncated-prefix guards, re-run here). Equal-weighting causal streams stays causal. +""" +from __future__ import annotations + +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") + +import importlib.util +import numpy as np +import pandas as pd + +import skyhooklib as sk +import altlib as al +from src.backtest.harness import backtest_signals +from src.strategies import skyhook as S +from src.strategies.skyhook import SkyhookParams + +HOLDOUT = sk.HOLDOUT +FEE = sk.FEE_RT + + +# --- import the two structural entry builders without running their __main__ ---------------- +def _load(modname, path): + spec = importlib.util.spec_from_file_location(modname, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +PCTL = _load("skr_pctl", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_PCTL.py") +EXPD = _load("skr_expd", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_EXPAND.py") + + +# --- the V2 winner from the prior wave (Chande/Donchian regime) ----------------------------- +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) + +# pattern/exit shared knobs for the structural members. Match the WINNER's exit profile +# (sl2.5/tp7.0, asym time exits) so the difference is the REGIME, not the exit. +def member_params(**kw): + base = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16) + base.update(kw) + return SkyhookParams(**base) + + +# ============================================================================================ +# Per-asset equity helpers. We need the FULL bar-level equity curve of each member so we can +# build the COMBINED equity (for an honest combined max-DD), AND the daily-return series (for +# Sharpe split + marginal). All on the SAME 230m execution index. +# ============================================================================================ +def member_equity(asset, kind, p, cfg=None): + """Return (eq, idx) bar-level equity for a member. kind in {'winner','pctl','expand'}.""" + ltf, htf = sk.frames(asset) + if kind == "winner": + ent = S.skyhook_entries(ltf, htf, p) + elif kind == "pctl": + ent = PCTL.pctl_entries(ltf, htf, p, **cfg) + elif kind == "expand": + ent = EXPD.expand_entries(ltf, htf, p, **cfg) + else: + raise ValueError(kind) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=asset, tf="230m") + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + return np.asarray(m.equity, float), idx, int(m.n_trades) + + +def eq_to_daily_ret(eq, idx): + """bar equity -> daily simple returns (last-of-day, ffill, pct_change).""" + s = pd.Series(eq, index=idx) + return s.resample("1D").last().ffill().pct_change() + + +def combined_daily_ret(asset, members): + """Equal-weight DAILY returns of the members on one asset -> combined daily return series. + Each member contributes its own daily return; the equal-weight portfolio return is the mean. + Members are aligned on the union of daily timestamps; a member that has not started yet + (NaN) contributes 0 that day and is dropped from the active-weight denominator (outer-join + with renormalized equal weights), so early days where only some members trade are handled.""" + drs = {} + ntr = {} + for name, kind, p, cfg in members: + eq, idx, nt = member_equity(asset, kind, p, cfg) + drs[name] = eq_to_daily_ret(eq, idx) + ntr[name] = nt + D = pd.concat(drs, axis=1, join="outer") + # renormalized equal weight across the members that are ACTIVE (non-NaN) each day + active = D.notna() + w = active.div(active.sum(axis=1).replace(0, np.nan), axis=0) + comb = (D.fillna(0.0) * w.fillna(0.0)).sum(axis=1) + comb = comb.dropna() + return comb, ntr + + +def combined_metrics(comb): + """Sharpe (full + hold-out) and max-DD from a DAILY combined return series.""" + comb = comb[np.isfinite(comb.values)] + eq = (1.0 + comb).cumprod().values + idx = comb.index + + def _sh(r): + r = r[np.isfinite(r)] + return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) and np.std(r) > 0 else 0.0 + + pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + full_sh = _sh(comb.values) + hmask = idx >= HOLDOUT + hold_sh = _sh(comb.values[hmask]) if hmask.sum() > 5 else 0.0 + # hold-out DD on the hold-out slice of the combined equity + eqh = eq[hmask] + pkh = np.maximum.accumulate(eqh) if len(eqh) else np.array([1.0]) + ddh = float(np.max((pkh - eqh) / pkh)) if len(eqh) else 0.0 + yrs = {} + for y in sorted(set(idx.year)): + rr = comb.values[idx.year == y] + yrs[int(y)] = round(float((1 + pd.Series(rr)).prod() - 1), 4) + return dict(full_sharpe=round(full_sh, 3), hold_sharpe=round(hold_sh, 3), + maxdd=round(dd, 4), hold_maxdd=round(ddh, 4), yearly=yrs) + + +# --- per-member standalone DD (for the correlation/decorrelation diagnostic) ---------------- +def member_standalone_dd(asset, kind, p, cfg=None): + eq, idx, nt = member_equity(asset, kind, p, cfg) + pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + return dd, nt + + +# ============================================================================================ +# Fee robustness of the ENSEMBLE: re-run every member at fee f, recombine, check Sharpe>0. +# ============================================================================================ +def ensemble_fee_sweep(members, fees=(0.0, 0.001, 0.002, 0.003)): + rows = {} + for f in fees: + ok = True + for asset in ("BTC", "ETH"): + drs = {} + for name, kind, p, cfg in members: + ltf, htf = sk.frames(asset) + if kind == "winner": + ent = S.skyhook_entries(ltf, htf, p) + elif kind == "pctl": + ent = PCTL.pctl_entries(ltf, htf, p, **cfg) + else: + ent = EXPD.expand_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=asset, tf="230m") + drs[name] = eq_to_daily_ret(np.asarray(m.equity, float), + pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + D = pd.concat(drs, axis=1, join="outer") + active = D.notna() + w = active.div(active.sum(axis=1).replace(0, np.nan), axis=0) + comb = (D.fillna(0.0) * w.fillna(0.0)).sum(axis=1).dropna() + sh = combined_metrics(comb)["full_sharpe"] + rows[(f, asset)] = sh + ok = ok and (sh > 0) + rows[(f, "ok")] = ok + return rows + + +def ensemble_daily_5050(members): + """50/50 BTC+ETH combined daily series for marginal_vs_tp01.""" + cb, _ = combined_daily_ret("BTC", members) + ce, _ = combined_daily_ret("ETH", members) + J = pd.concat({"BTC": cb, "ETH": ce}, axis=1, join="inner").fillna(0.0) + return 0.5 * J["BTC"] + 0.5 * J["ETH"] + + +# ============================================================================================ +def run_ensemble(tag, members): + print(f"\n========== ENSEMBLE: {tag} ==========") + print(f" members: {[m[0] for m in members]}") + per = {} + for asset in ("BTC", "ETH"): + comb, ntr = combined_daily_ret(asset, members) + met = combined_metrics(comb) + per[asset] = dict(met=met, ntr=ntr) + print(f" {asset}: FULL Sh={met['full_sharpe']:+.2f} HOLD Sh={met['hold_sharpe']:+.2f}" + f" maxDD={met['maxdd']*100:.0f}% holdDD={met['hold_maxdd']*100:.0f}% ntrades={ntr}") + print(f" yearly: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in met['yearly'].items())) + min_full = min(per[a]["met"]["full_sharpe"] for a in per) + min_hold = min(per[a]["met"]["hold_sharpe"] for a in per) + max_dd = max(per[a]["met"]["maxdd"] for a in per) + n_trades_min = min(sum(per[a]["ntr"].values()) for a in per) + print(f" -> minFull={min_full:+.2f} minHold={min_hold:+.2f} maxDD={max_dd*100:.0f}%" + f" nTrades(min,sum-of-members)={n_trades_min}") + return dict(per=per, min_full=min_full, min_hold=min_hold, max_dd=max_dd, + n_trades_min=n_trades_min) + + +if __name__ == "__main__": + print("=== SKH2_ENS_STRUCT: cross-definition regime ensemble (DD reduction) ===") + + # --- 0) Baseline: the V2 winner ALONE (reference) ----------------------------------- + print("\n--- V2 WINNER standalone (Chande/Donchian) ---") + w_per = {} + for a in ("BTC", "ETH"): + r = sk.run_asset(a, WINNER, FEE) + w_per[a] = r + print(f" {a}: FULL Sh={r['full']['sharpe']:+.2f} DD={r['full']['maxdd']*100:.0f}%" + f" n={r['full']['n_trades']} | HOLD Sh={r['holdout']['sharpe']:+.2f}") + w_minfull = min(w_per[a]["full"]["sharpe"] for a in w_per) + w_minhold = min(w_per[a]["holdout"]["sharpe"] for a in w_per) + w_maxdd = max(w_per[a]["full"]["maxdd"] for a in w_per) + print(f" WINNER: minFull={w_minfull:+.2f} minHold={w_minhold:+.2f} maxDD={w_maxdd*100:.0f}%") + + # --- 1) Structural member configs (different regime definitions) -------------------- + pP = member_params() + pE = member_params() + # PCTL: expanding percentile-rank regime. From SKH_R_PCTL_final, the lower-DD / robust + # band was the low-vola band; we use a mid band with a modest volume floor so it disagrees + # with the winner's HIGH-vola Chande band (different regime -> decorrelated DD). + PCTL_CFG = dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.0, vol_hi=1.0) + PCTL_CFG2 = dict(vola_win=None, vol_win=None, vola_lo=0.10, vola_hi=0.80, vol_lo=0.30, vol_hi=1.0) + # EXPAND: volatility-expansion regime (ATR above its MA + volume elevated). + EXP_CFG = dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=1.00) + EXP_CFG2 = dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.20) + + # --- standalone DD + pairwise correlation of the three regime streams (BTC) --------- + print("\n--- standalone member DD + pairwise daily-return corr (decorrelation check) ---") + for a in ("BTC", "ETH"): + dW, nW = member_standalone_dd(a, "winner", WINNER) + dP, nP = member_standalone_dd(a, "pctl", pP, PCTL_CFG) + dE, nE = member_standalone_dd(a, "expand", pE, EXP_CFG) + print(f" {a} standalone DD: winner={dW*100:.0f}%(n{nW}) pctl={dP*100:.0f}%(n{nP}) expand={dE*100:.0f}%(n{nE})") + # corr of daily returns + rw = eq_to_daily_ret(*member_equity(a, "winner", WINNER)[:2]) + rp = eq_to_daily_ret(*member_equity(a, "pctl", pP, PCTL_CFG)[:2]) + re_ = eq_to_daily_ret(*member_equity(a, "expand", pE, EXP_CFG)[:2]) + DD = pd.concat({"W": rw, "P": rp, "E": re_}, axis=1, join="inner").fillna(0.0) + cc = DD.corr() + print(f" corr W-P={cc.loc['W','P']:.2f} W-E={cc.loc['W','E']:.2f} P-E={cc.loc['P','E']:.2f}") + + # --- 2) Candidate ensembles --------------------------------------------------------- + candidates = { + "WPE (winner+pctlLo+expand)": [ + ("winner", "winner", WINNER, None), + ("pctl", "pctl", pP, PCTL_CFG), + ("expand", "expand", pE, EXP_CFG), + ], + "WP (winner+pctlLo)": [ + ("winner", "winner", WINNER, None), + ("pctl", "pctl", pP, PCTL_CFG), + ], + "WE (winner+expand)": [ + ("winner", "winner", WINNER, None), + ("expand", "expand", pE, EXP_CFG), + ], + "WPE2 (winner+pctlMid+expandStrong)": [ + ("winner", "winner", WINNER, None), + ("pctl", "pctl", pP, PCTL_CFG2), + ("expand", "expand", pE, EXP_CFG2), + ], + } + + results = {} + for tag, members in candidates.items(): + results[tag] = (members, run_ensemble(tag, members)) + + # --- 3) Pick best by: max_dd<0.30, then maximize min_hold --------------------------- + def score(v): + # prefer DD<0.30 AND min_hold>=0.65; among those, maximize min_hold then -DD + ok = v["max_dd"] < 0.30 and v["min_hold"] >= 0.65 + return (1 if ok else 0, v["min_hold"], -v["max_dd"]) + best_tag = max(results, key=lambda t: score(results[t][1])) + best_members, best_v = results[best_tag] + print(f"\n*** BEST ENSEMBLE = {best_tag} ***") + print(f" minFull={best_v['min_full']:+.2f} minHold={best_v['min_hold']:+.2f}" + f" maxDD={best_v['max_dd']*100:.0f}% nTradesMin={best_v['n_trades_min']}") + + # --- 4) Causality: winner via sk; structural members via their own guards ----------- + print("\n--- causality (all active members of best ensemble) ---") + caus_ok = True + kinds = {m[0]: (m[1], m[2], m[3]) for m in best_members} + if "winner" in kinds: + cb = sk.causality(WINNER, "BTC"); ce = sk.causality(WINNER, "ETH") + print(f" winner: BTC {cb} ETH {ce}") + caus_ok = caus_ok and cb["ok"] and ce["ok"] + if "pctl" in kinds: + _, p, cfg = kinds["pctl"] + cb = PCTL.check_causality(cfg, p, "BTC"); ce = PCTL.check_causality(cfg, p, "ETH") + print(f" pctl: BTC {cb} ETH {ce}") + caus_ok = caus_ok and cb["ok"] and ce["ok"] + if "expand" in kinds: + _, p, cfg = kinds["expand"] + cb = EXPD.check_causality(cfg, p, "BTC"); ce = EXPD.check_causality(cfg, p, "ETH") + print(f" expand: BTC {cb} ETH {ce}") + caus_ok = caus_ok and cb["ok"] and ce["ok"] + print(f" causality_ok (all members) = {caus_ok}") + + # --- 5) Fee sweep on the ensemble --------------------------------------------------- + print("\n--- ensemble fee sweep (FULL Sharpe per asset) ---") + fsw = ensemble_fee_sweep(best_members) + for f in (0.0, 0.001, 0.002, 0.003): + print(f" {f*100:.2f}%RT: BTC={fsw[(f,'BTC')]:+.2f} ETH={fsw[(f,'ETH')]:+.2f} ok={fsw[(f,'ok')]}") + fee_survives = fsw[(0.003, "ok")] + print(f" fee_survives 0.30%RT (both): {fee_survives}") + + # --- 6) Marginal vs TP01 on the ensemble 50/50 series ------------------------------- + print("\n--- marginal vs TP01 (best ensemble, 50/50 BTC+ETH) ---") + cand = ensemble_daily_5050(best_members) + marg = al.marginal_vs_tp01(cand) + corr_full = marg.get("corr_full") + verdict = marg.get("marginal_verdict") + has_edge = marg.get("has_insample_edge") + is_hedge = marg.get("is_hedge") + robust_oos = marg.get("robust_oos") + multicut = marg.get("multicut_persistent") + clean_year = marg.get("clean_year_uplift") + w25 = marg.get("blends", {}).get("w25", {}) + w50 = marg.get("blends", {}).get("w50", {}) + up25_hold = w25.get("uplift_hold") + print(f" corr_full={corr_full} corr_hold={marg.get('corr_hold')}") + print(f" marginal_verdict={verdict} robust_oos={robust_oos} multicut_persistent={multicut}") + print(f" has_insample_edge={has_edge} (cand_insample_sharpe={marg.get('cand_insample_sharpe')}) is_hedge={is_hedge}") + print(f" clean_year_uplift={clean_year} jackknife_min_uplift={marg.get('jackknife_min_uplift')}") + print(f" multicut_uplift={marg.get('multicut_uplift')}") + print(f" blend w25: uplift_hold={up25_hold} uplift_full={w25.get('uplift_full')} dd={w25.get('dd')}") + print(f" blend w50: full={w50.get('full')} hold={w50.get('hold')} uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}") + + # --- 7) Grade + earns_slot + beats_winner ------------------------------------------ + n_trades_min = best_v["n_trades_min"] + grade = "PASS" if (n_trades_min >= 20 and best_v["min_full"] >= 0.5 + and best_v["min_hold"] >= 0.2 and fee_survives) else \ + ("WEAK" if (n_trades_min >= 20 and best_v["min_full"] >= 0.3 + and best_v["min_hold"] >= 0.0) else "FAIL") + earns_slot = (grade != "FAIL") and verdict == "ADDS" and robust_oos and (not is_hedge) + beats = (earns_slot and best_v["max_dd"] < 0.30 + and (up25_hold is not None and up25_hold >= 0.55) + and best_v["min_hold"] >= 0.65) + + print("\n=========== FINAL ===========") + print(f"BEST CONFIG = {best_tag}") + print(f" members:") + for m in best_members: + print(f" {m[0]}: kind={m[1]} cfg={m[3]}") + print(f" minFull={best_v['min_full']:+.2f} minHold={best_v['min_hold']:+.2f}" + f" max_dd={best_v['max_dd']*100:.1f}% n_trades_min={n_trades_min}") + print(f" fee@0.30%RT survives={fee_survives} causality_ok={caus_ok} grade={grade}") + print(f" marginal: corr_full={corr_full} verdict={verdict} insample_edge={has_edge}" + f" is_hedge={is_hedge} robust_oos={robust_oos} multicut={multicut}") + print(f" clean_year_uplift={clean_year} blend_w25_uplift_hold={up25_hold}") + print(f" blend_w50: full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}") + print(f" earns_slot={earns_slot} beats_winner={beats}") + print(f"\n (WINNER ref: minFull={w_minfull:+.2f} minHold={w_minhold:+.2f} maxDD={w_maxdd*100:.0f}%)") + + # machine-readable tail for the agent + import json + print("\nRESULT_JSON=" + json.dumps({ + "best_tag": best_tag, + "best_config": {"members": [{"name": m[0], "kind": m[1], "cfg": m[3]} for m in best_members]}, + "min_full": best_v["min_full"], "min_hold": best_v["min_hold"], + "max_dd": best_v["max_dd"], "n_trades_min": n_trades_min, + "fee_survives": bool(fee_survives), "causality_ok": bool(caus_ok), "grade": grade, + "corr_full": corr_full, "marginal_verdict": verdict, "has_insample_edge": bool(has_edge), + "is_hedge": bool(is_hedge), "robust_oos": bool(robust_oos), + "multicut_persistent": bool(multicut), "clean_year_uplift": clean_year, + "blend_w25_uplift_hold": up25_hold, "earns_slot": bool(earns_slot), "beats_winner": bool(beats), + }, default=str)) diff --git a/scripts/research/skyhook/runs/SKH2_EXPAND_DD.py b/scripts/research/skyhook/runs/SKH2_EXPAND_DD.py new file mode 100644 index 0000000..586fa6d --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_EXPAND_DD.py @@ -0,0 +1,258 @@ +"""SKH2_EXPAND_DD — DD-reduction wave, vol-EXPANSION family. + +Family task: reuse the volatility-EXPANSION regime from SKH_R_EXPAND.py (ATR rising vs its own +MA AND volume elevated vs its own MA), monkeypatch S.htf_features, run sk.study, and TUNE +w_atr/k_atr/w_vol/k_vol + winner-style exits to: + (1) cut standalone maxDD below 30% (max over BTCÐ) <-- the only unmet wave goal + (2) keep min-asset HOLD-OUT Sharpe >= ~0.70 and earns_slot == True + (3) stretch: lift blend w25 uplift_hold and minHold. + +Mechanism / DD theory: + * the EXPANSION gate (vol rising + volume elevated) is itself a DD filter: it suppresses + entries during quiet/contracting chop where Donchian breakouts whipsaw. Tightening k_atr / + k_vol trades trade-count for cleaner regime -> fewer adverse entries. + * but per-trade loss size is set by sl_atr; the V2 winner used sl_atr=2.5 (DD 34/31%). + Lowering sl_atr is the direct DD lever. We sweep sl_atr in {1.6,1.8,2.0,2.2,2.5} and + couple it with the winner exits (uscitalong=24/uscitashort=16) and tp_atr in {5,6,7}. + * vola_lo/vola_hi/vol_lo bands are IRRELEVANT here: the expansion regime REPLACES the Chande + band gate (htf_features is monkeypatched), so those SkyhookParams fields are dead. Only + ptn_n / sl_atr / tp_atr / uscita* / max_per_day / long_only matter through the patched path. + +Everything causal: the expansion features use only x[0..i] (causal rolling MA, ATR ewm, donchian +shift(1)); HTF merged BACKWARD onto LTF on HTF-close ts. We verify with sk.causality (works +because we patch S.htf_features inside skyhooklib's namespace, so skyhook_entries uses our gate). +""" +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 + +# reuse the EXPANSION feature builder verbatim +from SKH_R_EXPAND import expand_htf_features + +ORIG_FEAT = S.htf_features +HOLDOUT = sk.HOLDOUT +FEE = sk.FEE_RT + + +def patched(cfg): + def _feat(htf, p): + return expand_htf_features(htf, p, **cfg) + return _feat + + +def study_expand(name, p, cfg, want_marginal=True): + """Run sk.study + causality (+ marginal) with htf_features patched to the expansion regime.""" + S.htf_features = patched(cfg) + try: + rep = sk.study(name, p) + caus_b = sk.causality(p, "BTC") + caus_e = sk.causality(p, "ETH") + marg = sk.marginal(p) if want_marginal else None + finally: + S.htf_features = ORIG_FEAT + return rep, (caus_b, caus_e), marg + + +def vline(rep): + v = rep["verdict"] + pa = rep["per_asset"] + mdd = max(pa[a]["full"]["maxdd"] for a in pa) + return (v["grade"], v["min_asset_full_sharpe"], v["min_asset_holdout_sharpe"], + v["min_trades"], mdd, v["fee_survives"]) + + +# --------------------------------------------------------------------------- +# WINNER baseline (Chande band, NOT expansion) for reference — verify the stated DD problem. +# --------------------------------------------------------------------------- +def winner_reference(): + p = 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) + rep = sk.study("WINNER-V2", p) # uses ORIG_FEAT (Chande band) — not patched + g, mf, mh, mt, mdd, fee = vline(rep) + print(f"[WINNER-V2 ref] grade={g} minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} " + f"maxDD={mdd*100:.0f}% feeOK={fee} " + f"(BTC DD {rep['per_asset']['BTC']['full']['maxdd']*100:.0f}% / " + f"ETH DD {rep['per_asset']['ETH']['full']['maxdd']*100:.0f}%)") + return mh + + +def earns_slot(rep, marg): + g = rep["verdict"]["grade"] != "FAIL" + return bool(g and marg.get("marginal_verdict") == "ADDS" + and marg.get("robust_oos") and not marg.get("is_hedge")) + + +if __name__ == "__main__": + print("=== SKH2_EXPAND_DD: vol-EXPANSION regime tuned for standalone maxDD < 30% ===\n") + win_minhold = winner_reference() + print() + + # ------------------------------------------------------------------- + # PASS 1: coarse grid. Regime gate strength (k_atr,k_vol,windows) x SL size. + # Goal: find DD<30% cells that keep minHold high. Marginal computed only for finalists. + # ------------------------------------------------------------------- + # exits: asymmetric time-exits. PASS1 learned that LONGER long-holds (us30/18 vs winner's + # 24/16) are what flip the marginal robust_oos gate POSITIVE (clean-2025-year uplift > 0) + # while sl_atr=2.4 keeps DD<30. So we sweep exits + sl_atr here, ptn_n fixed near winner. + base_kw = dict(ptn_n=45, uscitalong=30, uscitashort=18) + + # The EXPANSION gate REPLACES the Chande band (htf_features monkeypatched): vola_*/vol_* are + # dead. DD is cut by (a) the gate itself (only trade rising-vol + elevated-volume regimes) and + # (b) sl_atr. The a20/k1.1 gate + sl2.4 + us30/18 is the DD<30 + robust_oos sweet spot found. + regimes = { + # tag: expansion cfg + "r_a20k1.1_v20k1.1": dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.10), + "r_a25k1.1_v25k1.1": dict(w_atr=25, k_atr=1.10, w_vol=25, k_vol=1.10), + "r_a30k1.1_v30k1.1": dict(w_atr=30, k_atr=1.10, w_vol=30, k_vol=1.10), + "r_a18k1.1_v18k1.1": dict(w_atr=18, k_atr=1.10, w_vol=18, k_vol=1.10), + } + sl_grid = (2.2, 2.4) + tp_fixed = 7.0 + + print("--- PASS 1 coarse: regime x sl_atr (tp=7.0) ---") + pass1 = [] + for rtag, rcfg in regimes.items(): + for sl in sl_grid: + p = SkyhookParams(sl_atr=sl, tp_atr=tp_fixed, **base_kw) + S.htf_features = patched(rcfg) + try: + rep = sk.study(f"{rtag}_sl{sl}", p) + finally: + S.htf_features = ORIG_FEAT + g, mf, mh, mt, mdd, fee = vline(rep) + pass1.append((rtag, rcfg, sl, tp_fixed, g, mf, mh, mt, mdd, fee, p)) + print(f" {rtag:22s} sl{sl} -> grade={g:4s} minFull={mf:+.2f} minHold={mh:+.2f}" + f" minTr={mt:3d} maxDD={mdd*100:3.0f}% feeOK={fee}") + + # finalists = DD<30% AND minHold>=0.55 AND grade!=FAIL AND fee survives + fin = [r for r in pass1 if r[8] < 0.30 and r[6] >= 0.55 and r[4] != "FAIL" and r[9]] + print(f"\n--- PASS1 finalists (DD<30%, minHold>=0.6, !FAIL, feeOK): {len(fin)} ---") + for r in fin: + print(f" {r[0]} sl{r[2]} tp{r[3]} : minHold={r[6]:+.2f} DD={r[8]*100:.0f}%") + + # If none, relax to DD<30% AND minHold>=0.5 to still report best-effort. + if not fin: + fin = [r for r in pass1 if r[8] < 0.30 and r[6] >= 0.50 and r[4] != "FAIL" and r[9]] + print(f" (relaxed minHold>=0.5): {len(fin)}") + if not fin: + # last resort: lowest DD among non-FAIL fee-surviving with minHold>0 + cand = [r for r in pass1 if r[4] != "FAIL" and r[9] and r[6] > 0] + fin = sorted(cand, key=lambda r: r[8])[:3] + print(f" (last-resort lowest-DD): {len(fin)}") + + # ------------------------------------------------------------------- + # PASS 2: finalists -> full marginal + tighten tp around best. Pick the BEATS-WINNER one, + # else best earns_slot+lowest DD. + # ------------------------------------------------------------------- + # de-dup finalists by (rtag,sl) and cap to keep runtime sane + seen = set(); fin2 = [] + for r in sorted(fin, key=lambda r: (-r[6], r[8])): # prefer high minHold then low DD + key = (r[0], r[2]) + if key in seen: + continue + seen.add(key); fin2.append(r) + fin2 = fin2[:7] + + print(f"\n--- PASS 2 marginal on {len(fin2)} finalists ---") + results = [] + for r in fin2: + rtag, rcfg, sl, tp, g, mf, mh, mt, mdd, fee, p = r + rep, (cb, ce), marg = study_expand(f"{rtag}_sl{sl}_tp{tp}", p, rcfg) + g, mf, mh, mt, mdd, fee = vline(rep) + caus_ok = bool(cb["ok"] and ce["ok"]) + es = earns_slot(rep, marg) + w25 = marg.get("blends", {}).get("w25", {}) + w50 = marg.get("blends", {}).get("w50", {}) + uph = w25.get("uplift_hold") + beats = bool(es and mdd < 0.30 and (uph is not None and uph >= 0.55) and mh >= 0.65) + results.append(dict(tag=f"{rtag}_sl{sl}_tp{tp}", rcfg=rcfg, p=p, rep=rep, marg=marg, + caus_ok=caus_ok, earns=es, beats=beats, + minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee=fee, + uph=uph, w25=w25, w50=w50)) + print(f" {rtag}_sl{sl} -> grade={g} minFull={mf:+.2f} minHold={mh:+.2f} DD={mdd*100:.0f}%" + f" verdict={marg.get('marginal_verdict')} corr={marg.get('corr_full')}" + f" w25uplH={uph} earns={es} caus={caus_ok} BEATS={beats}") + + # ------------------------------------------------------------------- + # PASS 3: around the best finalist, try tp in {5,6} to see if tighter tp helps DD/minHold. + # ------------------------------------------------------------------- + def score(d): + # rank: beats first, then earns & DD<30, then minHold, then -DD + return (d["beats"], d["earns"] and d["maxDD"] < 0.30, d["minHold"], -d["maxDD"]) + + if results: + best = max(results, key=score) + rtag = best["tag"].rsplit("_sl", 1)[0] + rcfg = best["rcfg"] + sl = best["p"].sl_atr + # PASS3 sweeps the EXIT-BAR dimension: the robust_oos (2025-clean-year uplift) gate is + # set by the long-hold length. We probe uscitalong around 30 to confirm the sweet spot + # and hunt any DD<30 cell with higher blend uplift. + print(f"\n--- PASS 3 exit-bar refine around best regime={rtag} sl{sl} ---") + for usL, usS in ((28, 18), (32, 18), (30, 20)): + kw = dict(ptn_n=45, uscitalong=usL, uscitashort=usS) + p = SkyhookParams(sl_atr=sl, tp_atr=tp_fixed, **kw) + rep, (cb, ce), marg = study_expand(f"{rtag}_sl{sl}_us{usL}/{usS}", p, rcfg) + g, mf, mh, mt, mdd, fee = vline(rep) + caus_ok = bool(cb["ok"] and ce["ok"]) + es = earns_slot(rep, marg) + w25 = marg.get("blends", {}).get("w25", {}); w50 = marg.get("blends", {}).get("w50", {}) + uph = w25.get("uplift_hold") + beats = bool(es and mdd < 0.30 and (uph is not None and uph >= 0.55) and mh >= 0.65) + results.append(dict(tag=f"{rtag}_sl{sl}_us{usL}/{usS}", rcfg=rcfg, p=p, rep=rep, marg=marg, + caus_ok=caus_ok, earns=es, beats=beats, + minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee=fee, + uph=uph, w25=w25, w50=w50)) + print(f" us{usL}/{usS} -> grade={g} minFull={mf:+.2f} minHold={mh:+.2f} DD={mdd*100:.0f}%" + f" verdict={marg.get('marginal_verdict')} robust={marg.get('robust_oos')}" + f" w25uplH={uph} earns={es} BEATS={beats}") + + # ------------------------------------------------------------------- + # FINAL: pick best config and print full block. + # ------------------------------------------------------------------- + if not results: + print("\n!!! no finalists at all — reporting nothing meaningful. !!!") + sys.exit(0) + + best = max(results, key=score) + m = best["marg"]; rep = best["rep"] + print("\n" + "=" * 78) + print("FINAL BEST (vol-EXPANSION family)") + print("=" * 78) + print(f" tag = {best['tag']}") + print(f" regime cfg = {best['rcfg']}") + print(f" params = ptn_n={best['p'].ptn_n} sl_atr={best['p'].sl_atr} tp_atr={best['p'].tp_atr}" + f" uscitalong={best['p'].uscitalong} uscitashort={best['p'].uscitashort}" + f" max_per_day={best['p'].max_per_day} long_only={best['p'].long_only}") + print(f" minFull = {best['minFull']:+.3f}") + print(f" minHold = {best['minHold']:+.3f}") + print(f" max_dd = {best['maxDD']:.4f} ({best['maxDD']*100:.1f}%)") + print(f" n_trades = {best['minTr']} (min over BTCÐ)") + print(f" fee@0.30%RT survives = {best['fee']}") + print(f" causality OK (BTCÐ) = {best['caus_ok']}") + print(f" earns_slot = {best['earns']}") + print(f" BEATS_WINNER= {best['beats']}") + print(" -- per-asset --") + for a in ("BTC", "ETH"): + pa = rep["per_asset"][a] + print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} DD={pa['full']['maxdd']*100:.0f}%" + f" n={pa['full']['n_trades']} | HOLD Sh={pa['holdout']['sharpe']:+.2f}" + f" | fee_sweep {pa['fee_sweep']}") + print(" -- marginal vs TP01 --") + print(f" corr_full={m.get('corr_full')} corr_hold={m.get('corr_hold')}") + print(f" marginal_verdict={m.get('marginal_verdict')}") + print(f" has_insample_edge={m.get('has_insample_edge')} is_hedge={m.get('is_hedge')}") + print(f" robust_oos={m.get('robust_oos')} multicut_persistent={m.get('multicut_persistent')}") + print(f" clean_year_uplift={m.get('clean_year_uplift')} jackknife_min_uplift={m.get('jackknife_min_uplift')}") + print(f" cand_insample_sharpe={m.get('cand_insample_sharpe')} multicut_uplift={m.get('multicut_uplift')}") + print(f" blend w25={m.get('blends',{}).get('w25')}") + print(f" blend w50={m.get('blends',{}).get('w50')}") + print(f"\n win_minhold(reference)={win_minhold:+.2f}") diff --git a/scripts/research/skyhook/runs/SKH2_FREQ.py b/scripts/research/skyhook/runs/SKH2_FREQ.py new file mode 100644 index 0000000..519f6f3 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_FREQ.py @@ -0,0 +1,206 @@ +"""SKH2_FREQ — entry cadence / holding-period family for the SKH01 DD-reduction wave. + +Goal: cut standalone maxDD below 30% (max over BTC & ETH) while keeping min-asset HOLD-OUT +Sharpe >= ~0.70 and earns_slot == True. Lever space (all expressible via SkyhookParams): + * max_per_day {1, 2} + * uscitalong / uscitashort holding windows {12..30} + * atr_win (HTF) / ltf_atr_win (exec) windows + +Baseline-to-beat (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) + minFull +0.83 minHold +0.81 maxDD BTC34%/ETH31% earns_slot True + blend w25 uplift_hold +0.58, w50 full1.59/hold1.04/DD12.5%. + +A candidate BEATS the winner iff: earns_slot AND max_dd<0.30 AND blend_w25_uplift_hold>=0.55 +AND min_hold_sharpe>=0.65. +""" +from __future__ import annotations +import sys, json +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +# Winner base (all FREQ variants share the regime/pattern/stop structure of the winner). +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) + + +def mk(**over) -> SkyhookParams: + d = dict(WINNER); d.update(over) + return SkyhookParams(**d) + + +def quick(name, p) -> dict: + """Fast screen: FULL+HOLD on both assets + standalone maxDD. No fee sweep / marginal yet.""" + rb = sk.run_asset("BTC", p) + re = sk.run_asset("ETH", p) + minF = min(rb["full"]["sharpe"], re["full"]["sharpe"]) + minH = min(rb["holdout"]["sharpe"], re["holdout"]["sharpe"]) + maxdd = max(rb["full"]["maxdd"], re["full"]["maxdd"]) + minTr = min(rb["full"]["n_trades"], re["full"]["n_trades"]) + print(f" {name:38s} minF={minF:+.2f} minH={minH:+.2f} maxDD={maxdd*100:4.0f}% " + f"(B{rb['full']['maxdd']*100:.0f}/E{re['full']['maxdd']*100:.0f}) " + f"nTr={minTr} | Bh={rb['holdout']['sharpe']:+.2f} Eh={re['holdout']['sharpe']:+.2f}") + return dict(name=name, p=p, minF=minF, minH=minH, maxdd=maxdd, minTr=minTr, + bdd=rb["full"]["maxdd"], edd=re["full"]["maxdd"]) + + +def full_eval(name, p) -> dict: + rep = sk.study(name, p) + print(sk.fmt(rep)) + caus = sk.causality(p, "BTC") + causE = sk.causality(p, "ETH") + caus_ok = bool(caus["ok"] and causE["ok"]) + mg = sk.marginal(p) + v = rep["verdict"] + maxdd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"]) + w25 = mg.get("blends", {}).get("w25", {}) + w50 = mg.get("blends", {}).get("w50", {}) + earns_slot = (v["grade"] != "FAIL" and mg.get("marginal_verdict") == "ADDS" + and bool(mg.get("robust_oos")) and not bool(mg.get("is_hedge"))) + beats = (earns_slot and maxdd < 0.30 + and (w25.get("uplift_hold") or -9) >= 0.55 + and v["min_asset_holdout_sharpe"] >= 0.65) + print(f" causality BTC={caus} ETH={causE} -> ok={caus_ok}") + print(f" marginal: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')} " + f"corr_hold={mg.get('corr_hold')} insample_edge={mg.get('has_insample_edge')} " + f"hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')} " + f"multicut={mg.get('multicut_persistent')} cleanYr={mg.get('clean_year_uplift')}") + print(f" blends: w25 uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')} | " + f"w50 full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')} " + f"uplift_hold={w50.get('uplift_hold')}") + print(f" ==> maxDD={maxdd*100:.1f}% earns_slot={earns_slot} BEATS_WINNER={beats}") + return dict(name=name, p=p, rep=rep, mg=mg, caus_ok=caus_ok, maxdd=maxdd, + earns_slot=earns_slot, beats=beats, w25=w25, w50=w50, v=v) + + +if __name__ == "__main__": + print("="*100) + print("PHASE 1 — fast screen of cadence / holding / atr-window variants (FULL+HOLD+DD)") + print("="*100) + + screens = [] + # 0) reproduce the winner as a sanity anchor + screens.append(quick("WINNER(uL24/uS16,mpd1,atr14/14)", mk())) + + # --- Holding-window grid (the core DD lever): shorter holds cap single-trade risk. + print("\n-- holding windows (uscitalong/uscitashort), mpd=1 --") + for uL in (12, 16, 20, 24, 28, 30): + for uS in (10, 12, 14, 16, 20): + screens.append(quick(f"uL{uL}/uS{uS}", mk(uscitalong=uL, uscitashort=uS))) + + # --- max_per_day: 2 entries/day = more frequent re-entry (more fee, smaller clusters?) + print("\n-- max_per_day=2 across a few holds --") + for uL in (12, 16, 20, 24): + for uS in (10, 12, 16): + screens.append(quick(f"mpd2 uL{uL}/uS{uS}", mk(max_per_day=2, uscitalong=uL, uscitashort=uS))) + + # --- atr windows (HTF signal vola & exec stop sizing), at the WINNER hold (uL24/uS16) + # where DD was lowest, not the whipsaw uL16/uS12. + print("\n-- atr_win (HTF) x ltf_atr_win (exec), at WINNER hold uL24/uS16 --") + for aw in (10, 14, 20): + for lw in (10, 14, 20): + screens.append(quick(f"atr{aw}/ltf{lw} uL24/uS16", mk(atr_win=aw, ltf_atr_win=lw))) + + # --- targeted DD-reducers: mpd2 at the winner hold (smaller clusters, keep hold) + + # longer ATR for steadier stops; and asymmetric long-bias holds (long crypto = up-drift, + # so a longer long-hold + shorter short-hold protects the worst-asset short DD). + print("\n-- targeted DD-reducers (mpd2 @ winner hold; long-bias asym holds) --") + for cfg in ( + dict(max_per_day=2, uscitalong=24, uscitashort=16), + dict(max_per_day=2, uscitalong=24, uscitashort=16, ltf_atr_win=20), + dict(max_per_day=2, uscitalong=24, uscitashort=16, atr_win=20, ltf_atr_win=20), + dict(uscitalong=24, uscitashort=18, ltf_atr_win=20), + dict(uscitalong=28, uscitashort=18, ltf_atr_win=20), + dict(uscitalong=24, uscitashort=20, ltf_atr_win=20), + dict(max_per_day=2, uscitalong=20, uscitashort=16, ltf_atr_win=20), + dict(max_per_day=2, uscitalong=20, uscitashort=18, ltf_atr_win=20), + ): + nm = "DDr " + "/".join(f"{k}={v}" for k, v in cfg.items()) + screens.append(quick(nm, mk(**cfg))) + + # Rank by: meets DD<30 first, then by minH (we need hold >=0.65), then minF. + print("\n" + "="*100) + print("PHASE 2 — full eval of best DD-reducing candidates (study+causality+marginal)") + print("="*100) + + # candidates: prioritize LOWEST DD with a still-usable hold-out (minH>=0.55), but ALSO + # always include the global lowest-DD configs that keep minH>=0.5 (DD is the unmet goal). + pool = [s for s in screens if s["minTr"] >= 20] + a_set = [s for s in pool if s["maxdd"] < 0.36 and s["minH"] >= 0.55] + a_set.sort(key=lambda s: (s["maxdd"], -s["minH"])) + b_set = [s for s in pool if s["minH"] >= 0.50] + b_set.sort(key=lambda s: s["maxdd"]) # lowest DD overall (usable hold) + picked = [] + seen = set() + for s in a_set[:6] + b_set[:6]: + if s["name"] in seen: + continue + seen.add(s["name"]) + picked.append(s) + if len(picked) >= 9: + break + if not picked: + pool.sort(key=lambda s: s["maxdd"]) + picked = pool[:6] + + print("Picked for full eval (DD<0.32, minH>=0.55, nTr>=20), sorted by DD:") + for s in picked: + print(f" {s['name']:38s} maxDD={s['maxdd']*100:.0f}% minH={s['minH']:+.2f} minF={s['minF']:+.2f}") + + results = [] + for s in picked: + print("\n" + "-"*90) + results.append(full_eval(s["name"], s["p"])) + + # also full-eval the winner as the reference + print("\n" + "-"*90 + "\n[REFERENCE] WINNER full eval:") + rwin = full_eval("WINNER", mk()) + + # ---- pick the best config: prefer beats_winner, else lowest DD with earns_slot & best hold + print("\n" + "="*100) + print("FINAL RANKING") + print("="*100) + def score(r): + return (not r["beats"], not r["earns_slot"], r["maxdd"], -r["v"]["min_asset_holdout_sharpe"]) + allr = results + [rwin] + allr.sort(key=score) + for r in allr: + print(f" {r['name']:38s} beats={r['beats']} earns={r['earns_slot']} maxDD={r['maxdd']*100:.0f}% " + f"minF={r['v']['min_asset_full_sharpe']:+.2f} minH={r['v']['min_asset_holdout_sharpe']:+.2f} " + f"w25uH={r['w25'].get('uplift_hold')} caus={r['caus_ok']}") + + best = allr[0] + print("\n" + "="*100) + print("BEST CONFIG") + print("="*100) + bp = best["p"] + cfg = {k: getattr(bp, k) for k in bp.__dataclass_fields__} + print(f"name={best['name']}") + print(f"config={json.dumps(cfg)}") + print(f"minFull={best['v']['min_asset_full_sharpe']:+.3f}") + print(f"minHold={best['v']['min_asset_holdout_sharpe']:+.3f}") + print(f"max_dd={best['maxdd']:.4f}") + print(f"n_trades_min={best['v']['min_trades']}") + print(f"fee_survives_0.30%={best['v']['fee_survives']}") + print(f"causality_ok={best['caus_ok']}") + mg = best["mg"] + print(f"MARGINAL DICT: corr_full={mg.get('corr_full')} corr_hold={mg.get('corr_hold')} " + f"verdict={mg.get('marginal_verdict')} has_insample_edge={mg.get('has_insample_edge')} " + f"is_hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')} " + f"multicut_persistent={mg.get('multicut_persistent')} clean_year_uplift={mg.get('clean_year_uplift')}") + print(f"blend w25 uplift_hold={best['w25'].get('uplift_hold')} | " + f"w50 full={best['w50'].get('full')} hold={best['w50'].get('hold')} dd={best['w50'].get('dd')}") + print(f"earns_slot={best['earns_slot']} BEATS_WINNER={best['beats']}") + # dump machine-readable for final structured output + print("\nJSON_BEST=" + json.dumps(dict( + name=best["name"], config=cfg, minFull=best["v"]["min_asset_full_sharpe"], + minHold=best["v"]["min_asset_holdout_sharpe"], max_dd=best["maxdd"], + n_trades_min=best["v"]["min_trades"], fee_survives=best["v"]["fee_survives"], + causality_ok=best["caus_ok"], earns_slot=best["earns_slot"], beats=best["beats"], + corr_full=mg.get("corr_full"), marginal_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_persistent=mg.get("multicut_persistent"), + clean_year_uplift=mg.get("clean_year_uplift"), + w25_uplift_hold=best["w25"].get("uplift_hold")))) diff --git a/scripts/research/skyhook/runs/SKH2_KELTNER_PTN.py b/scripts/research/skyhook/runs/SKH2_KELTNER_PTN.py new file mode 100644 index 0000000..6ca6578 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_KELTNER_PTN.py @@ -0,0 +1,277 @@ +"""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)) diff --git a/scripts/research/skyhook/runs/SKH2_PATTERN_CONF.py b/scripts/research/skyhook/runs/SKH2_PATTERN_CONF.py new file mode 100644 index 0000000..83417ae --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_PATTERN_CONF.py @@ -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']}") diff --git a/scripts/research/skyhook/runs/SKH2_PCTL_DD.py b/scripts/research/skyhook/runs/SKH2_PCTL_DD.py new file mode 100644 index 0000000..d2258ad --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_PCTL_DD.py @@ -0,0 +1,289 @@ +"""SKH2_PCTL_DD — DD-reduction wave, family [PCTL_DD]. + +GOAL: cut STANDALONE maxDD below 30% (max over BTC & ETH) while keeping minHold>=~0.70 +and earns_slot==True, using the CAUSAL expanding/rolling PERCENTILE-RANK regime from +SKH_R_PCTL.py (reuse pctl_entries), tuned together with the winner's exits. + +Baseline to beat (V2 winner, Chande regime): + SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16, + vola_lo=35, vola_hi=95, vol_lo=0.0) + minFull +0.83, minHold +0.81, standalone DD BTC34%/ETH31% (THE PROBLEM), + marginal ADDS, blend w25 uplift_hold +0.58, blend 50/50 full1.59/hold1.04/DD12.5%. + +LEVERS FOR DD CUT (all causal, expressed through pctl_entries cfg + the SkyhookParams exits): + * percentile-rank regime bands (where ATR/volume sit in their own causal history): + - cap the upper vola band (avoid blow-off-vol entries that cluster losses) + - add a volume floor (live tape only) OR keep vol open + * tighter hard stop (sl_atr) caps per-trade loss -> shrinks DD + * the winner's wider tp_atr=7.0 + asym time exits (24/16) carried over. + +A candidate BEATS THE WINNER iff: earns_slot AND max_dd<0.30 AND blend_w25_uplift_hold>=0.55 +AND min_hold_sharpe>=0.65. We report TRUE numbers regardless. +""" +from __future__ import annotations + +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") + +import importlib.util +import numpy as np +import pandas as pd + +import skyhooklib as sk +import altlib as al +from src.backtest.harness import backtest_signals +from src.strategies import skyhook as S +from src.strategies.skyhook import SkyhookParams + +# import the structural pctl builder (pctl_entries, pctl_rank, _split) from the sweep script +spec = importlib.util.spec_from_file_location( + "skr", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_PCTL.py") +skr = importlib.util.module_from_spec(spec) +spec.loader.exec_module(skr) # __main__ guard prevents the sweep from running + +HOLDOUT = sk.HOLDOUT +FEE = sk.FEE_RT + + +# --------------------------------------------------------------------------- +# Build a SkyhookParams holding the WINNER's exits; only regime comes from pctl cfg. +# pctl_entries reads: ptn_n, sl_atr, tp_atr, uscitalong, uscitashort, exit_mode, ltf_atr_win, +# max_per_day, long_only (the regime bands come from the cfg kwargs). +# --------------------------------------------------------------------------- +def winner_exit_params(**kw): + base = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16) + base.update(kw) + return SkyhookParams(**base) + + +# --------------------------------------------------------------------------- +# Eval a (cfg, params) pair on both assets: FULL + HOLD via the honest engine. +# --------------------------------------------------------------------------- +def eval_pair(cfg, p): + out = {} + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = skr.pctl_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = skr._split(eq, idx, hmask) + out[a] = dict(full=full, hold=hold, + yearly={int(y): round(v, 4) for y, v in m.yearly.items()}) + return out + + +def summarize(res): + mf = min(res[a]["full"]["sharpe"] for a in res) + mh = min(res[a]["hold"]["sharpe"] for a in res) + mt = min(res[a]["full"]["n_trades"] for a in res) + mdd = max(res[a]["full"]["maxdd"] for a in res) + return dict(minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, res=res) + + +def line(tag, v): + r = v["res"] + print(f" [{tag:30s}] minFull={v['minFull']:+.2f} minHold={v['minHold']:+.2f} " + f"minTr={v['minTr']:3d} maxDD={v['maxDD']*100:4.0f}% | " + f"BTC F{r['BTC']['full']['sharpe']:+.2f}/H{r['BTC']['hold']['sharpe']:+.2f}/DD{r['BTC']['full']['maxdd']*100:.0f}% " + f"ETH F{r['ETH']['full']['sharpe']:+.2f}/H{r['ETH']['hold']['sharpe']:+.2f}/DD{r['ETH']['full']['maxdd']*100:.0f}%") + + +# --------------------------------------------------------------------------- +# Causality (truncated-prefix) on the structural pctl entries. +# --------------------------------------------------------------------------- +def check_causality(cfg, p, asset="BTC", tail=200): + return skr.check_causality(cfg, p, asset, tail=tail) + + +# --------------------------------------------------------------------------- +# Marginal vs TP01 on a (cfg, params) pair (50/50 daily, same convention as skyhooklib). +# --------------------------------------------------------------------------- +def marginal_struct(cfg, p): + def daily(a): + ltf, htf = sk.frames(a) + ent = skr.pctl_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + return s.resample("1D").last().ffill().pct_change().dropna() + sb, se = daily("BTC"), daily("ETH") + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + cand = 0.5 * J["BTC"] + 0.5 * J["ETH"] + return al.marginal_vs_tp01(cand) + + +def fee_sweep(cfg, p): + ok = True + rows = {} + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = skr.pctl_entries(ltf, htf, p, **cfg) + row = [] + for f in (0.0, 0.001, 0.002, 0.003): + m = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m") + row.append((f, round(m.sharpe, 3))) + rows[a] = row + ok = ok and (dict(row)[0.003] > 0) + return ok, rows + + +if __name__ == "__main__": + print("=== SKH2_PCTL_DD : percentile-rank regime tuned for DD<30 ===\n") + + # ----------------------------------------------------------------------- + # STAGE 1 — coarse sweep: regime bands (pctl space) x stop tightness. + # Winner exits (tp7/24/16) carried; we vary sl_atr and the regime cfg. + # Intuition for DD cut: + # - cap vola_hi (drop blow-off-vol entries) ; modest vol floor (live tape) + # - tighter sl_atr (2.0/1.8) caps per-trade loss. + # ----------------------------------------------------------------------- + print("--- STAGE 1: regime band x stop sweep (exits tp7/24/16) ---") + band_cfgs = { + # name: pctl regime cfg (expanding unless _r) + "volaHi95_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0), + "volaHi90_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.90, vol_lo=0.0, vol_hi=1.0), + "volaMid_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.25, vola_hi=0.85, vol_lo=0.0, vol_hi=1.0), + "volaMid_volFlr": dict(vola_win=None, vol_win=None, vola_lo=0.25, vola_hi=0.85, vol_lo=0.30, vol_hi=1.0), + "volaHi90_volFlr": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.90, vol_lo=0.30, vol_hi=1.0), + "volaCap80_volFlr":dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.80, vol_lo=0.30, vol_hi=1.0), + } + sls = [2.5, 2.0, 1.8] + stage1 = {} + for bname, cfg in band_cfgs.items(): + for sl in sls: + p = winner_exit_params(sl_atr=sl) + tag = f"{bname}|sl{sl}" + v = summarize(eval_pair(cfg, p)) + stage1[tag] = (cfg, p, v) + line(tag, v) + + # Pick DD<30 candidates with the best minHold (need minHold>=~0.7). + sub30 = {t: tup for t, tup in stage1.items() if tup[2]["maxDD"] < 0.30} + print(f"\n--- STAGE 1: configs with maxDD<30%: {len(sub30)} ---") + for t, (_, _, v) in sorted(sub30.items(), key=lambda kv: -kv[1][2]["minHold"]): + line(t, v) + + # ----------------------------------------------------------------------- + # STAGE 2 — refine: take best DD<30 (and near-30 with high hold) candidates, + # fine-tune bands/stop to push minHold up while keeping DD<30. + # ----------------------------------------------------------------------- + print("\n--- STAGE 2: refinement around best DD<30 / high-hold cells ---") + refine = { + # tighter blow-off cap + small vol floor, sl 1.8-2.0 + "R1": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=2.0)), + "R2": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=1.8)), + "R3": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.88, vol_lo=0.30, vol_hi=1.0), winner_exit_params(sl_atr=2.0)), + "R4": (dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.85, vol_lo=0.30, vol_hi=1.0), winner_exit_params(sl_atr=2.0)), + # rolling-window regime (recent), which reacts faster to regime shift -> may cut DD + "R5": (dict(vola_win=120, vol_win=120, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=2.0)), + "R6": (dict(vola_win=120, vol_win=120, vola_lo=0.30, vola_hi=0.85, vol_lo=0.30, vol_hi=1.0), winner_exit_params(sl_atr=1.8)), + # tighter tp to bank faster (lower DD) with tight sl + "R7": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=2.0, tp_atr=6.0)), + "R8": (dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.85, vol_lo=0.20, vol_hi=1.0), winner_exit_params(sl_atr=1.6)), + } + stage2 = {} + for t, (cfg, p) in refine.items(): + v = summarize(eval_pair(cfg, p)) + stage2[t] = (cfg, p, v) + line(t, v) + + # ----------------------------------------------------------------------- + # PICK BEST: among ALL cells, prefer maxDD<0.30 AND minHold>=0.65; rank by + # (DD<30) then minHold then -DD. Fall back to best minHold if none sub-30. + # ----------------------------------------------------------------------- + allcells = {**stage1, **stage2} + def score(tup): + v = tup[2] + dd_ok = v["maxDD"] < 0.30 + hold_ok = v["minHold"] >= 0.65 + full_ok = v["minFull"] >= 0.5 + tr_ok = v["minTr"] >= 20 + # primary: meets all gates; secondary: minHold; tertiary: lower DD + return (dd_ok and hold_ok and full_ok and tr_ok, dd_ok, v["minHold"], -v["maxDD"]) + best_tag = max(allcells, key=lambda t: score(allcells[t])) + best_cfg, best_p, best_v = allcells[best_tag] + print(f"\n*** SELECTED = {best_tag} ***") + line(best_tag, best_v) + + # ----------------------------------------------------------------------- + # FULL VERIFICATION on selected: causality + fee sweep + marginal. + # ----------------------------------------------------------------------- + print("\n--- causality (truncated-prefix) ---") + cB = check_causality(best_cfg, best_p, "BTC") + cE = check_causality(best_cfg, best_p, "ETH") + causality_ok = bool(cB["ok"] and cE["ok"]) + print(f" BTC={cB} ETH={cE} -> causality_ok={causality_ok}") + + print("\n--- fee sweep (FULL sharpe) ---") + fee_ok, frows = fee_sweep(best_cfg, best_p) + for a, row in frows.items(): + print(f" {a}: " + " ".join(f"{f*100:.2f}%={s:+.2f}" for f, s in row)) + print(f" fee_survives 0.30%RT (both): {fee_ok}") + + print("\n--- marginal vs TP01 (selected) ---") + marg = marginal_struct(best_cfg, best_p) + corr_full = marg.get("corr_full") + verdict = marg.get("marginal_verdict") + has_edge = marg.get("has_insample_edge") + is_hedge = marg.get("is_hedge") + robust_oos = marg.get("robust_oos") + multicut = marg.get("multicut_persistent") + clean_yr = marg.get("clean_year_uplift") + w25 = marg.get("blends", {}).get("w25", {}) + w50 = marg.get("blends", {}).get("w50", {}) + up_h = w25.get("uplift_hold") + print(f" corr_full={corr_full} corr_hold={marg.get('corr_hold')}") + print(f" marginal_verdict={verdict} robust_oos={robust_oos} multicut_persistent={multicut}") + print(f" has_insample_edge={has_edge} is_hedge={is_hedge} cand_insample_sharpe={marg.get('cand_insample_sharpe')}") + print(f" clean_year_uplift={clean_yr} jackknife_min_uplift={marg.get('jackknife_min_uplift')}") + print(f" blend w25: uplift_hold={up_h} uplift_full={w25.get('uplift_full')}") + print(f" blend w50: full={w50.get('full')} hold={w50.get('hold')} uplift_hold={w50.get('uplift_hold')} dd={w50.get('dd')}") + + # grade (mirror sk verdict thresholds): PASS if minTr>=20 & minFull>=0.5 & minHold>=0.2 & feeOK + mf, mh, mt, mdd = best_v["minFull"], best_v["minHold"], best_v["minTr"], best_v["maxDD"] + if mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok: + grade = "PASS" + elif mt >= 20 and mf >= 0.3 and mh >= 0.0: + grade = "WEAK" + else: + grade = "FAIL" + earns_slot = (grade != "FAIL") and (verdict == "ADDS") and bool(robust_oos) and (not bool(is_hedge)) + beats_winner = bool(earns_slot and mdd < 0.30 and (up_h is not None and up_h >= 0.55) and mh >= 0.65) + + print("\n" + "=" * 70) + print("FINAL BLOCK — SKH2_PCTL_DD") + print("=" * 70) + print(f"best_cfg(regime) = {best_cfg}") + print(f"best_params = ptn_n={best_p.ptn_n} sl_atr={best_p.sl_atr} tp_atr={best_p.tp_atr} " + f"uscitalong={best_p.uscitalong} uscitashort={best_p.uscitashort} exit_mode={best_p.exit_mode}") + print(f"grade={grade}") + print(f"minFull={mf:+.3f} minHold={mh:+.3f} max_dd={mdd:.4f} ({mdd*100:.0f}%) n_trades_min={mt}") + print(f"fee@0.30%RT survives={fee_ok} causality_ok={causality_ok}") + print(f"marginal: verdict={verdict} corr_full={corr_full} has_insample_edge={has_edge} " + f"is_hedge={is_hedge} robust_oos={robust_oos} multicut_persistent={multicut} clean_year_uplift={clean_yr}") + print(f"blend w25 uplift_hold={up_h} blend w50 full={w50.get('full')}/hold={w50.get('hold')}/dd={w50.get('dd')}") + print(f"earns_slot={earns_slot}") + print(f"beats_winner={beats_winner}") + print("=" * 70) + + # machine-readable echo for the agent + import json + print("RESULT_JSON=" + json.dumps({ + "best_cfg": best_cfg, + "best_params": {"ptn_n": best_p.ptn_n, "sl_atr": best_p.sl_atr, "tp_atr": best_p.tp_atr, + "uscitalong": best_p.uscitalong, "uscitashort": best_p.uscitashort, + "exit_mode": best_p.exit_mode, + "vola_lo": best_cfg["vola_lo"], "vola_hi": best_cfg["vola_hi"], + "vol_lo": best_cfg["vol_lo"], "vol_hi": best_cfg["vol_hi"], + "vola_win": best_cfg["vola_win"], "vol_win": best_cfg["vol_win"]}, + "grade": grade, "minFull": mf, "minHold": mh, "max_dd": mdd, "n_trades_min": mt, + "fee_ok": fee_ok, "causality_ok": causality_ok, + "marginal_verdict": verdict, "corr_full": corr_full, "has_insample_edge": has_edge, + "is_hedge": is_hedge, "robust_oos": robust_oos, "multicut_persistent": multicut, + "clean_year_uplift": clean_yr, "blend_w25_uplift_hold": up_h, + "w50_full": w50.get("full"), "w50_hold": w50.get("hold"), "w50_dd": w50.get("dd"), + "earns_slot": earns_slot, "beats_winner": beats_winner, + }, default=str)) diff --git a/scripts/research/skyhook/runs/SKH2_REGIME_TIGHT.py b/scripts/research/skyhook/runs/SKH2_REGIME_TIGHT.py new file mode 100644 index 0000000..0f850d2 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_REGIME_TIGHT.py @@ -0,0 +1,163 @@ +"""SKH2_REGIME_TIGHT — DD-reduction wave, family: tighter regime selectivity. + +Hypothesis: make the regime band MORE selective (narrow vola band, add a volume floor) +so only the cleanest setups trade -> fewer, higher-quality entries -> lower standalone DD, +while keeping the winner's asymmetric exits (sl 2.5 / tp 7.0, uscitalong 24 / short 16). + +Baseline winner to beat (V2): + SkyhookParams(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16, + vola_lo=35, vola_hi=95, vol_lo=0) + minFull +0.83 minHold +0.81 ; DD BTC 34% / ETH 31% (the unmet goal: DD<30%) ; + marginal ADDS, corr_full 0.05, blend w25 uplift_hold +0.58, w50 full 1.59 / hold 1.04 / DD 12.5%. + +BEATS-THE-WINNER iff: earns_slot AND max_dd<0.30 AND blend_w25_uplift_hold>=0.55 AND min_hold>=0.65. + +Everything is param-based (no new feature) -> sk.study/sk.marginal/sk.causality are directly valid. +""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import itertools +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +# --- the winner's exits, frozen ---------------------------------------------------------- +EXITS = dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16) + + +def mk(vola_lo, vola_hi, vol_lo, **extra): + return SkyhookParams(vola_lo=vola_lo, vola_hi=vola_hi, vol_lo=vol_lo, **EXITS, **extra) + + +def earns_slot(rep, mg): + return (rep["verdict"]["grade"] != "FAIL" + and mg.get("marginal_verdict") == "ADDS" + and bool(mg.get("robust_oos")) + and not bool(mg.get("is_hedge"))) + + +def beats(rep, mg, max_dd): + es = earns_slot(rep, mg) + upl = mg.get("blends", {}).get("w25", {}).get("uplift_hold") + mh = rep["verdict"]["min_asset_holdout_sharpe"] + return (es and max_dd < 0.30 and (upl is not None and upl >= 0.55) and mh >= 0.65) + + +def summarize(name, p): + rep = sk.study(name, p) + v = rep["verdict"] + dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"]) + nt = v["min_trades"] + print(sk.fmt(rep)) + print(f" >> maxDD(BTC,ETH) = {dd*100:.1f}% minTrades={nt}") + return rep, dd + + +if __name__ == "__main__": + # ---- STAGE 1: coarse grid over the family levers ----------------------------------- + vola_los = [35.0, 40.0, 45.0, 50.0] # 35 = winner; 40/45/50 = tighter floor + vola_his = [85.0, 90.0, 95.0] # 95 = winner; 85/90 = clip blow-off harder + vol_los = [0.0, 30.0, 40.0, 50.0] # 0 = winner; floor = require live volume + + rows = [] + print("########## STAGE 1: coarse DD scan (study, both assets) ##########") + for vlo, vhi, vol in itertools.product(vola_los, vola_his, vol_los): + p = mk(vlo, vhi, vol) + rep = sk.study(f"vlo{vlo:.0f}_vhi{vhi:.0f}_vol{vol:.0f}", p) + v = rep["verdict"] + dd = max(rep["per_asset"][a]["full"]["maxdd"] for a in rep["per_asset"]) + nt = v["min_trades"] + ddb = rep["per_asset"]["BTC"]["full"]["maxdd"] + dde = rep["per_asset"]["ETH"]["full"]["maxdd"] + rows.append(dict(vlo=vlo, vhi=vhi, vol=vol, grade=v["grade"], + mf=v["min_asset_full_sharpe"], mh=v["min_asset_holdout_sharpe"], + dd=dd, ddb=ddb, dde=dde, nt=nt, fee=v["fee_survives"])) + print(f" vlo{vlo:.0f} vhi{vhi:.0f} vol{vol:.0f}: {v['grade']:4s} " + f"mF={v['min_asset_full_sharpe']:+.2f} mH={v['min_asset_holdout_sharpe']:+.2f} " + f"DD={dd*100:4.1f}% (B{ddb*100:.0f}/E{dde*100:.0f}) nT={nt:3d} feeOK={v['fee_survives']}") + + # ---- rank: candidates with DD<30%, minTrades>=20, fee OK, holdout>=0.65 ------------- + print("\n########## STAGE 1 RANK (filter DD<30, nT>=20, fee OK, mH>=0.65, not FAIL) ##########") + cand = [r for r in rows if r["dd"] < 0.30 and r["nt"] >= 20 and r["fee"] + and r["mh"] >= 0.65 and r["grade"] != "FAIL"] + cand.sort(key=lambda r: (-r["mh"], r["dd"])) # prefer high holdout then low DD + if not cand: + print(" (none met all hard filters; falling back to lowest-DD with nT>=20 & not FAIL)") + cand = [r for r in rows if r["nt"] >= 20 and r["grade"] != "FAIL"] + cand.sort(key=lambda r: (r["dd"], -r["mh"])) + for r in cand[:8]: + print(f" vlo{r['vlo']:.0f} vhi{r['vhi']:.0f} vol{r['vol']:.0f}: {r['grade']} " + f"mF={r['mf']:+.2f} mH={r['mh']:+.2f} DD={r['dd']*100:.1f}% nT={r['nt']}") + + # ---- STAGE 2: full diligence (causality + marginal) on top few --------------------- + print("\n########## STAGE 2: causality + marginal on top candidates ##########") + best = None + top = cand[:4] + for r in top: + p = mk(r["vlo"], r["vhi"], r["vol"]) + name = f"TIGHT vlo{r['vlo']:.0f}_vhi{r['vhi']:.0f}_vol{r['vol']:.0f}" + print(f"\n----- {name} -----") + rep, dd = summarize(name, p) + cau = sk.causality(p, "BTC") + cau_e = sk.causality(p, "ETH") + cau_ok = bool(cau["ok"] and cau_e["ok"]) + mg = sk.marginal(p) + es = earns_slot(rep, mg) + bw = beats(rep, mg, dd) and cau_ok + w25 = mg.get("blends", {}).get("w25", {}) + w50 = mg.get("blends", {}).get("w50", {}) + print(f" causality BTC={cau} ETH={cau_e} -> ok={cau_ok}") + print(f" marginal: verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')} " + f"corr_hold={mg.get('corr_hold')} insample_edge={mg.get('has_insample_edge')} " + f"(cand_is_sh={mg.get('cand_insample_sharpe')}) hedge={mg.get('is_hedge')} " + f"robust_oos={mg.get('robust_oos')} multicut={mg.get('multicut_persistent')} " + f"clean_year_uplift={mg.get('clean_year_uplift')}") + print(f" blend w25: uplift_hold={w25.get('uplift_hold')} uplift_full={w25.get('uplift_full')} " + f"| w50: full={w50.get('full')} hold={w50.get('hold')} dd={w50.get('dd')}") + print(f" >> earns_slot={es} beats_winner={bw}") + cand_obj = dict(name=name, p=p, rep=rep, dd=dd, mg=mg, cau_ok=cau_ok, + es=es, bw=bw, w25=w25, w50=w50, + mf=rep["verdict"]["min_asset_full_sharpe"], + mh=rep["verdict"]["min_asset_holdout_sharpe"], + nt=rep["verdict"]["min_trades"], + fee=rep["verdict"]["fee_survives"]) + # pick best: prefer beats_winner, then earns_slot+lowDD, then lowDD + def keyf(c): + return (c["bw"], c["es"], -c["dd"], c["mh"]) + if best is None or keyf(cand_obj) > keyf(best): + best = cand_obj + + # ---- FINAL BLOCK ------------------------------------------------------------------- + print("\n\n==================== FINAL — REGIME_TIGHT BEST ====================") + if best is None: + print("NO CANDIDATE — no config passed even the soft filter.") + sys.exit(0) + b = best + pf = {k: getattr(b["p"], k) for k in b["p"].__dataclass_fields__} + mg = b["mg"] + print(f"config: vola_lo={b['p'].vola_lo} vola_hi={b['p'].vola_hi} vol_lo={b['p'].vol_lo} " + f"ptn_n={b['p'].ptn_n} sl_atr={b['p'].sl_atr} tp_atr={b['p'].tp_atr} " + f"uscitalong={b['p'].uscitalong} uscitashort={b['p'].uscitashort}") + print(f"minFull={b['mf']:+.3f} minHold={b['mh']:+.3f} max_dd={b['dd']*100:.1f}% " + f"n_trades_min={b['nt']} fee@0.30%OK={b['fee']} causality_ok={b['cau_ok']}") + print(f"earns_slot={b['es']} beats_winner={b['bw']}") + print("FULL marginal dict:") + for k in ("corr_full", "corr_hold", "marginal_verdict", "has_insample_edge", + "cand_insample_sharpe", "is_hedge", "robust_oos", "multicut_persistent", + "clean_year_uplift", "jackknife_min_uplift", "multicut_uplift"): + print(f" {k} = {mg.get(k)}") + print(f" blend w25: {mg.get('blends', {}).get('w25')}") + print(f" blend w50: {mg.get('blends', {}).get('w50')}") + print("PARAMS:", pf) + # machine-readable tail for me to parse + import json + print("\nRESULT_JSON " + json.dumps(dict( + family="REGIME_TIGHT", params=pf, + minFull=b["mf"], minHold=b["mh"], max_dd=b["dd"], n_trades_min=b["nt"], + fee_ok=b["fee"], causality_ok=b["cau_ok"], earns_slot=b["es"], beats=b["bw"], + corr_full=mg.get("corr_full"), marginal_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_persistent=mg.get("multicut_persistent"), + clean_year_uplift=mg.get("clean_year_uplift"), + w25_uplift_hold=mg.get("blends", {}).get("w25", {}).get("uplift_hold"), + ), default=str)) diff --git a/scripts/research/skyhook/runs/SKH2_TPSL_DD.py b/scripts/research/skyhook/runs/SKH2_TPSL_DD.py new file mode 100644 index 0000000..ac81a14 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_TPSL_DD.py @@ -0,0 +1,191 @@ +"""SKH2_TPSL_DD: RR / stop fine grid around the V2 winner to push standalone maxDD < 30% +while holding min-asset HOLD-OUT >= ~0.70 and earns_slot True. + +Winner baseline: + 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) + -> minFull +0.83, minHold +0.81, DD BTC 34% / ETH 31% (THE PROBLEM), earns_slot True. + +Family task: sl_atr in {1.75,2.0,2.25,2.5}, tp_atr in {5,6,7,8}, exit_mode 'pct' vs 'atr'. +Tighter SL cuts DD but can lower hold-out. Find DD<30 cell + minHold>=0.7 + plateau. +""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import numpy as np # noqa: E402 +import skyhooklib as sk # noqa: E402 +from src.strategies.skyhook import SkyhookParams # noqa: E402 + +# Winner fixed (non-RR) fields: +BASE = dict(ptn_n=45, uscitalong=24, uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + + +def mk(sl_atr=None, tp_atr=None, exit_mode="atr", sl_pct=None, tp_pct=None): + kw = dict(BASE) + kw["exit_mode"] = exit_mode + if exit_mode == "atr": + kw["sl_atr"] = sl_atr + kw["tp_atr"] = tp_atr + else: + kw["sl_pct"] = sl_pct + kw["tp_pct"] = tp_pct + return SkyhookParams(**kw) + + +def metrics(p): + """FULL/HOLD/DD min-asset + fee@0.30 + trades, both assets.""" + pa = {} + fee_ok = True + for a in ("BTC", "ETH"): + r = sk.run_asset(a, p) + # fee sweep at 0.30% + rf = sk.run_asset(a, p, fee_rt=0.003) + fee_ok = fee_ok and (rf["full"]["sharpe"] > 0) + pa[a] = dict(full=r["full"], hold=r["holdout"], yearly=r["yearly"], + fee30=rf["full"]["sharpe"]) + minFull = min(pa[a]["full"]["sharpe"] for a in pa) + minHold = min(pa[a]["hold"]["sharpe"] for a in pa) + minTr = min(pa[a]["full"]["n_trades"] for a in pa) + maxDD = max(pa[a]["full"]["maxdd"] for a in pa) + return dict(pa=pa, minFull=minFull, minHold=minHold, minTr=minTr, maxDD=maxDD, fee_ok=fee_ok) + + +def grade_of(m): + enough = m["minTr"] >= 20 + if enough and m["minFull"] >= 0.5 and m["minHold"] >= 0.2 and m["fee_ok"]: + return "PASS" + if enough and m["minFull"] >= 0.3 and m["minHold"] >= 0.0: + return "WEAK" + return "FAIL" + + +if __name__ == "__main__": + # ---- STAGE 1: coarse ATR grid (the family core) ---- + sl_grid = [1.75, 2.0, 2.25, 2.5] + tp_grid = [5.0, 6.0, 7.0, 8.0] + rows = [] + print("##### STAGE 1: ATR grid (sl_atr x tp_atr) #####") + print(f"{'sl':>5} {'tp':>4} | {'minFull':>8} {'minHold':>8} {'maxDD%':>7} {'minTr':>6} " + f"{'BTC_DD':>7} {'ETH_DD':>7} {'feeOK':>5} {'grade':>5}") + for sl in sl_grid: + for tp in tp_grid: + p = mk(sl_atr=sl, tp_atr=tp, exit_mode="atr") + m = metrics(p) + g = grade_of(m) + bdd = m["pa"]["BTC"]["full"]["maxdd"] + edd = m["pa"]["ETH"]["full"]["maxdd"] + rows.append((sl, tp, "atr", m, g)) + print(f"{sl:>5} {tp:>4} | {m['minFull']:>+8.2f} {m['minHold']:>+8.2f} " + f"{m['maxDD']*100:>7.1f} {m['minTr']:>6} {bdd*100:>7.1f} {edd*100:>7.1f} " + f"{str(m['fee_ok']):>5} {g:>5}") + + # ---- STAGE 2: pct exit grid (a few sensible RR pairs ~ matching ATR ratios) ---- + # ATR LTF ~ a few % of price; pct exit gives a HARD dollar cap on DD per trade. + print("\n##### STAGE 2: PCT exit grid (sl_pct x tp_pct) #####") + print(f"{'slP':>6} {'tpP':>6} | {'minFull':>8} {'minHold':>8} {'maxDD%':>7} {'minTr':>6} " + f"{'BTC_DD':>7} {'ETH_DD':>7} {'feeOK':>5} {'grade':>5}") + pct_pairs = [(0.02, 0.06), (0.025, 0.07), (0.03, 0.075), (0.03, 0.09), + (0.035, 0.10), (0.04, 0.10), + # dense neighbourhood around the DD<30 winner (0.025,0.07) to prove a plateau: + (0.0225, 0.065), (0.0225, 0.07), (0.0225, 0.075), + (0.025, 0.0625), (0.025, 0.065), (0.025, 0.075), (0.025, 0.08), + (0.0275, 0.065), (0.0275, 0.07), (0.0275, 0.075)] + for slp, tpp in pct_pairs: + p = mk(exit_mode="pct", sl_pct=slp, tp_pct=tpp) + m = metrics(p) + g = grade_of(m) + bdd = m["pa"]["BTC"]["full"]["maxdd"] + edd = m["pa"]["ETH"]["full"]["maxdd"] + rows.append((slp, tpp, "pct", m, g)) + print(f"{slp:>6} {tpp:>6} | {m['minFull']:>+8.2f} {m['minHold']:>+8.2f} " + f"{m['maxDD']*100:>7.1f} {m['minTr']:>6} {bdd*100:>7.1f} {edd*100:>7.1f} " + f"{str(m['fee_ok']):>5} {g:>5}") + + # ---- Pick best: DD<30, minHold>=0.7, grade!=FAIL; tie-break by minHold then minFull ---- + def ok_dd(r): + return r[3]["maxDD"] < 0.30 and r[3]["minHold"] >= 0.70 and r[4] != "FAIL" + cands = [r for r in rows if ok_dd(r)] + if not cands: + # relax: DD<30 and minHold>=0.65 + cands = [r for r in rows if r[3]["maxDD"] < 0.30 and r[3]["minHold"] >= 0.65 and r[4] != "FAIL"] + relaxed = True + else: + relaxed = False + if not cands: + # fall back to lowest DD among non-FAIL with decent hold + cands = [r for r in rows if r[4] != "FAIL"] + # rank: among DD<30 cells, maximize a balanced score (minHold + minFull) so we don't pick a + # low-DD-but-weak-Sharpe corner. DD is already gated < 0.30 above, so optimise value next. + cands_sorted = sorted(cands, key=lambda r: -(r[3]["minHold"] + r[3]["minFull"])) + best = cands_sorted[0] + print(f"\n##### BEST PICK (relaxed={relaxed if cands else 'fallback'}): " + f"{'sl/tp' if best[2]=='atr' else 'slP/tpP'}=({best[0]},{best[1]}) mode={best[2]} #####") + + # Build best params + if best[2] == "atr": + bp = mk(sl_atr=best[0], tp_atr=best[1], exit_mode="atr") + best_cfg = dict(ptn_n=45, sl_atr=best[0], tp_atr=best[1], uscitalong=24, + uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0, + exit_mode="atr") + else: + bp = mk(exit_mode="pct", sl_pct=best[0], tp_pct=best[1]) + best_cfg = dict(ptn_n=45, sl_pct=best[0], tp_pct=best[1], uscitalong=24, + uscitashort=16, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0, + exit_mode="pct") + + # ---- Full verification of best: study + causality + marginal ---- + print("\n##### FULL STUDY of BEST #####") + rep = sk.study("SKH2_TPSL_DD-BEST", bp) + print(sk.fmt(rep)) + caus = sk.causality(bp, "BTC") + caus_eth = sk.causality(bp, "ETH") + print(f"\ncausality BTC: {caus}") + print(f"causality ETH: {caus_eth}") + mg = sk.marginal(bp) + + m = best[3] + g = rep["verdict"]["grade"] + earns = (g != "FAIL" and mg.get("marginal_verdict") == "ADDS" + and bool(mg.get("robust_oos")) and not bool(mg.get("is_hedge"))) + w25 = mg.get("blends", {}).get("w25", {}) + w50 = mg.get("blends", {}).get("w50", {}) + beats = (earns and m["maxDD"] < 0.30 + and (w25.get("uplift_hold") or -9) >= 0.55 and m["minHold"] >= 0.65) + + print("\n========== FINAL BLOCK ==========") + print(f"best_cfg = {best_cfg}") + print(f"exit_mode = {best[2]}") + print(f"minFull = {m['minFull']:+.3f}") + print(f"minHold = {m['minHold']:+.3f}") + print(f"max_dd (BTC/ETH) = {m['maxDD']:.4f} (BTC {m['pa']['BTC']['full']['maxdd']:.4f} / " + f"ETH {m['pa']['ETH']['full']['maxdd']:.4f})") + print(f"n_trades_min = {m['minTr']}") + print(f"fee@0.30 OK = {m['fee_ok']} (BTC {m['pa']['BTC']['fee30']:+.2f} / " + f"ETH {m['pa']['ETH']['fee30']:+.2f})") + print(f"causality_ok = {caus['ok'] and caus_eth['ok']} " + f"(BTC mism={caus['mismatches']} ETH mism={caus_eth['mismatches']})") + print(f"grade = {g}") + print("--- marginal vs TP01 ---") + print(f"corr_full = {mg.get('corr_full')}") + print(f"corr_hold = {mg.get('corr_hold')}") + print(f"marginal_verdict = {mg.get('marginal_verdict')}") + print(f"has_insample_edge = {mg.get('has_insample_edge')}") + print(f"is_hedge = {mg.get('is_hedge')}") + print(f"robust_oos = {mg.get('robust_oos')}") + print(f"multicut_persist = {mg.get('multicut_persistent')}") + print(f"clean_year_uplift = {mg.get('clean_year_uplift')}") + print(f"jackknife_min_upl = {mg.get('jackknife_min_uplift')}") + print(f"cand_insample_sh = {mg.get('cand_insample_sharpe')}") + print(f"blend w25 = {w25}") + print(f"blend w50 = {w50}") + print(f"earns_slot = {earns}") + print(f"BEATS_WINNER = {beats}") + + # ---- plateau report: neighbors of best in the same mode ---- + print("\n##### PLATEAU (neighbors of best) #####") + nbrs = [r for r in rows if r[2] == best[2]] + nbrs_sorted = sorted(nbrs, key=lambda r: (r[3]["maxDD"])) + for r in nbrs_sorted[:8]: + tag = f"({r[0]},{r[1]})" + print(f" {r[2]} {tag:>14}: DD={r[3]['maxDD']*100:5.1f}% minFull={r[3]['minFull']:+.2f} " + f"minHold={r[3]['minHold']:+.2f} grade={r[4]}") diff --git a/scripts/research/skyhook/runs/SKH2_VOLTGT.py b/scripts/research/skyhook/runs/SKH2_VOLTGT.py new file mode 100644 index 0000000..9bc86c6 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH2_VOLTGT.py @@ -0,0 +1,322 @@ +"""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)) diff --git a/scripts/research/skyhook/runs/SKH_P_CHANDE.py b/scripts/research/skyhook/runs/SKH_P_CHANDE.py new file mode 100644 index 0000000..faa2c94 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_CHANDE.py @@ -0,0 +1,102 @@ +"""SKH_P_CHANDE — Chande-window sweep on the V1 base. + +TASK: on the V1 base, sweep n_vola and n_volume (the Chande momentum windows that drive +BuzVola=Chande01(ATR) and BuzVolume=Chande01(volume)) in {8,13,21,34,55}. Does a different +vol/volume CYCLE window help the REGIME gate out-of-sample? Maximize min HOLD-OUT Sharpe. + +V1 reference: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + with n_vola=n_volume=13 (defaults) -> minFull +0.69, minHold +0.64 (BTC 0.64 / ETH 0.64). + +We keep EVERYTHING from V1 fixed (bands, pattern, exits) and vary only the two Chande windows. +Rank by min HOLD-OUT subject to minFull>=0.5 and >=20 trades both assets, then plateau-check +the winner (neighbors in the n_vola x n_volume grid must also be good), then full study + +causality + marginal. +""" +from __future__ import annotations +import sys, itertools +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +ASSETS = ("BTC", "ETH") + +# V1 base: everything except the two Chande windows stays fixed. +BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + +WINDOWS = [8, 13, 21, 34, 55] # Fibonacci-ish cycle lengths for the Chande momentum + + +def mk(n_vola, n_volume): + return SkyhookParams(n_vola=n_vola, n_volume=n_volume, **BASE) + + +def eval_combo(n_vola, n_volume): + p = mk(n_vola, n_volume) + res = {a: sk.run_asset(a, p, sk.FEE_RT) for a in ASSETS} + min_full = min(res[a]["full"]["sharpe"] for a in ASSETS) + min_hold = min(res[a]["holdout"]["sharpe"] for a in ASSETS) + min_tr = min(res[a]["full"]["n_trades"] for a in ASSETS) + max_dd = max(res[a]["full"]["maxdd"] for a in ASSETS) + return dict(n_vola=n_vola, n_volume=n_volume, + min_full=min_full, min_hold=min_hold, min_tr=min_tr, max_dd=max_dd, + btc_hold=res["BTC"]["holdout"]["sharpe"], eth_hold=res["ETH"]["holdout"]["sharpe"], + btc_full=res["BTC"]["full"]["sharpe"], eth_full=res["ETH"]["full"]["sharpe"]) + + +def main(): + rows = [] + combos = list(itertools.product(WINDOWS, WINDOWS)) + print(f"Sweeping {len(combos)} (n_vola x n_volume) combos x 2 assets = {len(combos)*2} run_asset calls") + for nv, nvol in combos: + rows.append(eval_combo(nv, nvol)) + + # V1 reference cell (n_vola=n_volume=13) for explicit comparison + v1 = next(r for r in rows if r["n_vola"] == 13 and r["n_volume"] == 13) + print(f"\nV1 cell (n_vola=13,n_volume=13): minF={v1['min_full']:+.2f} minH={v1['min_hold']:+.2f} " + f"tr={v1['min_tr']} DD={v1['max_dd']*100:.0f}% (btcH={v1['btc_hold']:+.2f} ethH={v1['eth_hold']:+.2f})") + + valid = [r for r in rows if r["min_full"] >= 0.5 and r["min_tr"] >= 20] + valid.sort(key=lambda r: r["min_hold"], reverse=True) + + print("\n=== ALL combos by min HOLD-OUT (minF>=0.5 & tr>=20 marked OK) ===") + print(f"{'n_vola':>7}{'n_vol':>6} {'minF':>6}{'minH':>6}{'minTr':>6}{'maxDD':>7} {'btcH':>6}{'ethH':>6} ok") + for r in sorted(rows, key=lambda r: r["min_hold"], reverse=True): + ok = "OK" if (r["min_full"] >= 0.5 and r["min_tr"] >= 20) else "." + mark = " <-V1" if (r["n_vola"] == 13 and r["n_volume"] == 13) else "" + print(f"{r['n_vola']:>7}{r['n_volume']:>6} {r['min_full']:>6.2f}{r['min_hold']:>6.2f}" + f"{r['min_tr']:>6}{r['max_dd']*100:>6.0f}% {r['btc_hold']:>6.2f}{r['eth_hold']:>6.2f} {ok}{mark}") + + if not valid: + print("\nNo valid combo (minFull>=0.5 & >=20 trades). Best raw by minHold:") + print(sorted(rows, key=lambda r: r["min_hold"], reverse=True)[0]) + return + + top = valid[0] + print(f"\n=== WINNER: n_vola={top['n_vola']} n_volume={top['n_volume']} ===") + print(f" minFull={top['min_full']:+.2f} minHold={top['min_hold']:+.2f} minTr={top['min_tr']} maxDD={top['max_dd']*100:.0f}%") + + # plateau grid (full minHold table laid out as n_vola rows x n_volume cols) + def find(nv, nvol): + return next((r for r in rows if r["n_vola"] == nv and r["n_volume"] == nvol), None) + print("\n Plateau grid (minHold; rows=n_vola, cols=n_volume):") + print(" " + "".join(f"{nvol:>7}" for nvol in WINDOWS)) + for nv in WINDOWS: + cells = [] + for nvol in WINDOWS: + r = find(nv, nvol) + tag = "*" if (nv == top['n_vola'] and nvol == top['n_volume']) else " " + cells.append(f"{r['min_hold']:>6.2f}{tag}") + print(f" nv={nv:>3} " + "".join(cells)) + + # final study + causality + marginal on the winner + p = mk(top['n_vola'], top['n_volume']) + print("\n=== STUDY (winner) ===") + rep = sk.study(f"SKH_P_CHANDE_nv{top['n_vola']}_nvol{top['n_volume']}", p) + print(sk.fmt(rep)) + print("\ncausality:", sk.causality(p)) + print("\nmarginal:", sk.marginal(p)) + print("\nas_json:", sk.as_json(rep)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/skyhook/runs/SKH_P_EXITBARS.py b/scripts/research/skyhook/runs/SKH_P_EXITBARS.py new file mode 100644 index 0000000..5468dce --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_EXITBARS.py @@ -0,0 +1,100 @@ +"""SKH_P_EXITBARS — sweep the asymmetric time-exit horizons on the V1 base. + +V1 base: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + defaults uscitalong=24, uscitashort=18 -> minFull +0.69, HOLD +0.64 (BTC 0.64/ETH 0.64), + DD ~40-49% (HIGH). + +The asymmetry (long held longer than short) is core to Skyhook. Sweep: + uscitalong in {16,20,24,30,40} (LTF 230m bars max-hold for longs) + uscitashort in {10,14,18,24} (LTF 230m bars max-hold for shorts) + +Objective (priority): maximize min-asset HOLD-OUT subject to minFull>=0.5, minTrades>=20 BOTH +assets, fee survives 0.30%RT, causality ok. Secondary: cut standalone DD toward <30%. +Compare to V1 (minHold +0.64, DD ~40-49%). +""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +# V1 base (everything except the two exit horizons we sweep) +BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + +UL_GRID = [16, 20, 24, 30, 40] # uscitalong +US_GRID = [10, 14, 18, 24] # uscitashort + + +def cell(ul, us): + p = SkyhookParams(uscitalong=ul, uscitashort=us, **BASE) + out = {} + for a in ("BTC", "ETH"): + out[a] = sk.run_asset(a, p, fee_rt=sk.FEE_RT) + min_full = min(out[a]["full"]["sharpe"] for a in out) + min_hold = min(out[a]["holdout"]["sharpe"] for a in out) + min_tr = min(out[a]["full"]["n_trades"] for a in out) + max_dd = max(out[a]["full"]["maxdd"] for a in out) + return dict(ul=ul, us=us, min_full=min_full, min_hold=min_hold, + min_tr=min_tr, max_dd=max_dd, + btc_full=out["BTC"]["full"]["sharpe"], eth_full=out["ETH"]["full"]["sharpe"], + btc_hold=out["BTC"]["holdout"]["sharpe"], eth_hold=out["ETH"]["holdout"]["sharpe"], + btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"]) + + +print("=== SKH_P_EXITBARS sweep (V1 base ptn_n=55 sl2.5 tp6) — fee=0.10%RT ===") +print(f"{'uL':>4} {'uS':>4} | {'minFull':>7} {'minHold':>7} {'minTr':>5} {'maxDD':>6} | " + f"{'btcF':>5} {'ethF':>5} {'btcH':>5} {'ethH':>5} {'btcDD':>5} {'ethDD':>5}") +results = [] +for ul in UL_GRID: + for us in US_GRID: + c = cell(ul, us) + results.append(c) + flag = " *" if (c["min_full"] >= 0.5 and c["min_tr"] >= 20) else "" + marker = " 4} {us:>4} | {c['min_full']:>+7.2f} {c['min_hold']:>+7.2f} " + f"{c['min_tr']:>5} {c['max_dd']*100:>5.0f}% | " + f"{c['btc_full']:>+5.2f} {c['eth_full']:>+5.2f} " + f"{c['btc_hold']:>+5.2f} {c['eth_hold']:>+5.2f} " + f"{c['btc_dd']*100:>4.0f}% {c['eth_dd']*100:>4.0f}%{flag}{marker}") + +# Eligible = minFull>=0.5 AND minTrades>=20. Rank by min_hold, tie-break lower maxDD. +elig = [c for c in results if c["min_full"] >= 0.5 and c["min_tr"] >= 20] +print(f"\nEligible cells (minFull>=0.5, minTr>=20): {len(elig)}") +if not elig: + print("No eligible cell — V1 exit horizons may already be at/near the frontier.") + sys.exit(0) + +elig_hold = sorted(elig, key=lambda c: (-round(c["min_hold"], 3), c["max_dd"])) +print("Top by minHold (tie-break lower maxDD):") +for c in elig_hold[:6]: + print(f" uL={c['ul']} uS={c['us']}: minHold={c['min_hold']:+.2f} " + f"minFull={c['min_full']:+.2f} maxDD={c['max_dd']*100:.0f}% minTr={c['min_tr']}") + +dd_cands = sorted(elig, key=lambda c: (c["max_dd"], -round(c["min_hold"], 3))) +print("\nTop by lowest maxDD (DD-cut objective):") +for c in dd_cands[:6]: + print(f" uL={c['ul']} uS={c['us']}: maxDD={c['max_dd']*100:.0f}% " + f"minHold={c['min_hold']:+.2f} minFull={c['min_full']:+.2f} minTr={c['min_tr']}") + +best = elig_hold[0] +print(f"\n=== STUDY on best-by-minHold (uL={best['ul']} uS={best['us']}) ===") +pbest = SkyhookParams(uscitalong=best["ul"], uscitashort=best["us"], **BASE) +rep = sk.study(f"P_EXITBARS_uL{best['ul']}_uS{best['us']}", pbest) +print(sk.fmt(rep)) +caus = sk.causality(pbest) +print("causality:", caus) +mg = sk.marginal(pbest) +print("marginal:", {k: v for k, v in mg.items() + if k in ("corr_full", "marginal_verdict", "has_insample_edge", + "is_hedge", "robust_oos")}) +print("blend w25 uplift_hold:", mg.get("blends", {}).get("w25", {}).get("uplift_hold")) +print("\nAS_JSON_STUDY:", sk.as_json(rep)) + +# If the DD-cut frontier differs from the headline pick, study it too (cheap, one config). +ddbest = dd_cands[0] +if (ddbest["ul"], ddbest["us"]) != (best["ul"], best["us"]) and ddbest["min_hold"] >= 0.2: + print(f"\n=== STUDY on lowest-DD eligible (uL={ddbest['ul']} uS={ddbest['us']}) ===") + pdd = SkyhookParams(uscitalong=ddbest["ul"], uscitashort=ddbest["us"], **BASE) + repdd = sk.study(f"P_EXITBARS_DDcut_uL{ddbest['ul']}_uS{ddbest['us']}", pdd) + print(sk.fmt(repdd)) + print("causality:", sk.causality(pdd)) diff --git a/scripts/research/skyhook/runs/SKH_P_LOCAL.py b/scripts/research/skyhook/runs/SKH_P_LOCAL.py new file mode 100644 index 0000000..dde4c02 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_LOCAL.py @@ -0,0 +1,76 @@ +"""SKH_P_LOCAL — coordinate/local search around SKH01-V1. + +V1: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + -> minFull +0.69, minHold +0.64 (BTC0.64/ETH0.64), maxDD ~49% (BTC), clean_year(2025)=0.014 + robust_oos=False ONLY because clean_year_uplift 0.014 < 0.02. + +GOAL: (a) push 2025 clean-year uplift > 0.02 (-> robust_oos True, fully earns slot), + (b) cut DD toward <35%, keeping minHold>=0.5, minFull>=0.5, fee survives 0.30%RT, >=20 trades. + +Strategy: V1's 2025 is weak (BTC+2/ETH-2). Cleaner regime gating + tighter SL can both lift the +2025 contribution AND cut the BTC DD. Local coordinate sweep on the high-leverage knobs, each near V1. +""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + +def evalp(**over): + p = SkyhookParams(**{**V1, **over}) + out = {} + for a in ("BTC", "ETH"): + out[a] = sk.run_asset(a, p, fee_rt=sk.FEE_RT) + min_full = min(out[a]["full"]["sharpe"] for a in out) + min_hold = min(out[a]["holdout"]["sharpe"] for a in out) + min_tr = min(out[a]["full"]["n_trades"] for a in out) + max_dd = max(out[a]["full"]["maxdd"] for a in out) + return dict(p=p, over=over, min_full=min_full, min_hold=min_hold, min_tr=min_tr, max_dd=max_dd, + btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"], + btc_h=out["BTC"]["holdout"]["sharpe"], eth_h=out["ETH"]["holdout"]["sharpe"]) + +def row(tag, r): + elig = (r["min_full"] >= 0.5 and r["min_tr"] >= 20) + print(f"{tag:<28} minFull={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} " + f"maxDD={r['max_dd']*100:>3.0f}% (btc{r['btc_dd']*100:.0f}/eth{r['eth_dd']*100:.0f}) " + f"minTr={r['min_tr']:>3} {'OK' if elig else 'x'}") + return r + +print("=== SKH_P_LOCAL coordinate search around V1 (fee 0.10%RT) ===") +base = row("V1", evalp()) +cands = [("V1", base)] + +# Axis 1: SL tighter to cut DD (V1 sl=2.5). Lower sl -> lower DD, but may cut hold. +for sl in (1.75, 2.0, 2.25, 2.5): + for tp in (5.0, 6.0, 7.0): + cands.append((f"sl{sl}_tp{tp}", row(f"sl{sl}_tp{tp}", evalp(sl_atr=sl, tp_atr=tp)))) + +# Axis 2: regime band — tighten vola top (avoid blow-off) & raise vola_lo to skip dead vol. +for vlo, vhi in ((40,90),(45,90),(40,85),(35,90),(45,85),(50,90)): + cands.append((f"vola{vlo}-{vhi}", row(f"vola{vlo}-{vhi}", evalp(vola_lo=float(vlo), vola_hi=float(vhi))))) + +# Axis 3: add a volume floor (V1 vol_lo=0 = no vol gate). A floor concentrates into live regimes. +for vol_lo in (30.0, 40.0, 50.0): + cands.append((f"vol_lo{vol_lo}", row(f"vol_lo{vol_lo}", evalp(vol_lo=vol_lo)))) + +# Axis 4: ptn_n around 55. +for ptn in (45, 50, 60, 65): + cands.append((f"ptn{ptn}", row(f"ptn{ptn}", evalp(ptn_n=ptn)))) + +# Axis 5: exit bars (asymmetry). +for ul, us in ((24,18),(30,18),(20,14),(28,14)): + cands.append((f"ex{ul}/{us}", row(f"ex{ul}/{us}", evalp(uscitalong=ul, uscitashort=us)))) + +# Filter eligible (the constraints), rank by min_hold then lower DD. +elig = [(t,r) for (t,r) in cands if r["min_full"] >= 0.5 and r["min_tr"] >= 20 and r["min_hold"] >= 0.5] +print(f"\nEligible (minFull>=0.5, minHold>=0.5, minTr>=20): {len(elig)}") +elig.sort(key=lambda tr: (-round(tr[1]["min_hold"],3), tr[1]["max_dd"])) +for t,r in elig[:10]: + print(f" {t:<22} minHold={r['min_hold']:+.2f} minFull={r['min_full']:+.2f} maxDD={r['max_dd']*100:.0f}% over={r['over']}") + +# Low-DD subset +print("\nLowest-DD eligible:") +for t,r in sorted(elig, key=lambda tr:(tr[1]["max_dd"], -tr[1]["min_hold"]))[:8]: + print(f" {t:<22} maxDD={r['max_dd']*100:.0f}% minHold={r['min_hold']:+.2f} minFull={r['min_full']:+.2f} over={r['over']}") diff --git a/scripts/research/skyhook/runs/SKH_P_LOCAL2.py b/scripts/research/skyhook/runs/SKH_P_LOCAL2.py new file mode 100644 index 0000000..b807316 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_LOCAL2.py @@ -0,0 +1,65 @@ +"""SKH_P_LOCAL2 — refine: combine the two winning axes (ptn_n DD-cut + sl/tp hold-lift) +and CHECK MARGINAL clean-year(2025) uplift on the top few, since that is the true gate +(robust_oos requires clean_year_uplift>0.02 AND multicut_persistent).""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + +def evalp(**over): + p = SkyhookParams(**{**V1, **over}) + out = {a: sk.run_asset(a, p, fee_rt=sk.FEE_RT) for a in ("BTC","ETH")} + return dict(p=p, over=over, + min_full=min(out[a]["full"]["sharpe"] for a in out), + min_hold=min(out[a]["holdout"]["sharpe"] for a in out), + min_tr=min(out[a]["full"]["n_trades"] for a in out), + max_dd=max(out[a]["full"]["maxdd"] for a in out), + btc_dd=out["BTC"]["full"]["maxdd"], eth_dd=out["ETH"]["full"]["maxdd"]) + +def row(tag, r): + elig = (r["min_full"]>=0.5 and r["min_tr"]>=20 and r["min_hold"]>=0.5) + print(f"{tag:<26} minFull={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} " + f"maxDD={r['max_dd']*100:>3.0f}% (b{r['btc_dd']*100:.0f}/e{r['eth_dd']*100:.0f}) " + f"minTr={r['min_tr']:>3} {'OK' if elig else 'x'}") + return r + +print("=== SKH_P_LOCAL2 combine ptn_n x sl/tp (fee 0.10%RT) ===") +cands=[] +for ptn in (40, 45, 48): + for sl,tp in ((2.0,6.0),(2.0,7.0),(2.25,6.0),(2.25,7.0),(2.5,7.0)): + over=dict(ptn_n=ptn, sl_atr=sl, tp_atr=tp) + cands.append((f"ptn{ptn}_sl{sl}_tp{tp}", row(f"ptn{ptn}_sl{sl}_tp{tp}", evalp(**over)))) +# also ptn45 with exit-bar asymmetry that lifted hold +for ul,us in ((30,18),(24,18)): + over=dict(ptn_n=45, uscitalong=ul, uscitashort=us) + cands.append((f"ptn45_ex{ul}/{us}", row(f"ptn45_ex{ul}/{us}", evalp(**over)))) + +elig=[(t,r) for (t,r) in cands if r["min_full"]>=0.5 and r["min_tr"]>=20 and r["min_hold"]>=0.5] +elig.sort(key=lambda tr:(tr[1]["max_dd"], -round(tr[1]["min_hold"],3))) +print(f"\nEligible: {len(elig)} (sorted by lowest DD)") +for t,r in elig: + print(f" {t:<24} maxDD={r['max_dd']*100:.0f}% minHold={r['min_hold']:+.2f} minFull={r['min_full']:+.2f} over={r['over']}") + +# Check MARGINAL clean-year uplift on the lowest-DD eligible + the best-hold eligible. +def marg_check(tag, over): + p = SkyhookParams(**{**V1, **over}) + mg = sk.marginal(p) + print(f"\n--- MARGINAL {tag} over={over} ---") + print(f" verdict={mg.get('marginal_verdict')} corr_full={mg.get('corr_full')} " + f"robust_oos={mg.get('robust_oos')} multicut_persistent={mg.get('multicut_persistent')}") + print(f" clean_year_uplift={mg.get('clean_year_uplift')} jackknife_min={mg.get('jackknife_min_uplift')} " + f"has_insample_edge={mg.get('has_insample_edge')} is_hedge={mg.get('is_hedge')}") + print(f" blend w25 uplift_hold={mg.get('blends',{}).get('w25',{}).get('uplift_hold')} " + f"uplift_full={mg.get('blends',{}).get('w25',{}).get('uplift_full')}") + return mg + +# pick up to 4 distinct configs to marginal-check +seen=set(); checked=0 +for t,r in elig: + key=tuple(sorted(r["over"].items())) + if key in seen: continue + seen.add(key); marg_check(t, r["over"]); checked+=1 + if checked>=5: break diff --git a/scripts/research/skyhook/runs/SKH_P_LOCAL_final.py b/scripts/research/skyhook/runs/SKH_P_LOCAL_final.py new file mode 100644 index 0000000..13ec4c8 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_LOCAL_final.py @@ -0,0 +1,48 @@ +"""SKH_P_LOCAL_final — full study + causality + marginal on the top local-search winners, +plus a small extra pass trying to push DD<35% while keeping minHold high (ptn45 + tighter exits).""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + +def evalp(**over): + p = SkyhookParams(**{**V1, **over}) + out = {a: sk.run_asset(a, p, fee_rt=sk.FEE_RT) for a in ("BTC","ETH")} + return dict(over=over, + min_full=min(out[a]["full"]["sharpe"] for a in out), + min_hold=min(out[a]["holdout"]["sharpe"] for a in out), + min_tr=min(out[a]["full"]["n_trades"] for a in out), + max_dd=max(out[a]["full"]["maxdd"] for a in out)) + +# Small extra: ptn45 + tp7 + tighter SL or exit bars to chase DD<35 with hold>=0.5 +print("=== extra DD-chase around ptn45 ===") +for over in (dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16), + dict(ptn_n=45, sl_atr=2.25, tp_atr=7.0, uscitalong=24, uscitashort=16), + dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, vola_lo=40.0), + dict(ptn_n=45, sl_atr=2.5, tp_atr=8.0)): + r=evalp(**over) + print(f" {over} -> minFull={r['min_full']:+.2f} minHold={r['min_hold']:+.2f} maxDD={r['max_dd']*100:.0f}% minTr={r['min_tr']}") + +# WINNER candidates -> full study +WINNERS = { + "P_LOCAL_ptn45_sl2.5_tp7.0": dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0), # best balanced (DD36, Full0.83, Hold0.69) + "P_LOCAL_ptn45_sl2.25_tp7.0": dict(ptn_n=45, sl_atr=2.25, tp_atr=7.0), # best hold/clean-year (DD36, Hold0.77) +} +for name, over in WINNERS.items(): + p = SkyhookParams(**{**V1, **over}) + print(f"\n################ STUDY {name} over={over} ################") + rep = sk.study(name, p) + print(sk.fmt(rep)) + print("causality:", sk.causality(p)) + mg = sk.marginal(p) + keys=("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge", + "robust_oos","multicut_persistent","clean_year_uplift","jackknife_min_uplift", + "cand_insample_sharpe","cand_full_sharpe") + print("marginal:", {k: mg.get(k) for k in keys}) + print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold"), + "uplift_full:", mg.get("blends",{}).get("w25",{}).get("uplift_full")) + print("multicut:", mg.get("multicut_uplift")) + print("AS_JSON:", sk.as_json(rep)) diff --git a/scripts/research/skyhook/runs/SKH_P_LOCAL_v1ref.py b/scripts/research/skyhook/runs/SKH_P_LOCAL_v1ref.py new file mode 100644 index 0000000..864fb98 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_LOCAL_v1ref.py @@ -0,0 +1,18 @@ +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) +print("=== V1 reference ===") +rep = sk.study("SKH01-V1", V1) +print(sk.fmt(rep)) +print("causality:", sk.causality(V1)) +mg = sk.marginal(V1) +keys = ("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge", + "robust_oos","multicut_persistent","clean_year_uplift","jackknife_min_uplift", + "cand_insample_sharpe","cand_full_sharpe") +print("marginal:", {k: mg.get(k) for k in keys}) +print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold")) +print("blend w25 uplift_full:", mg.get("blends",{}).get("w25",{}).get("uplift_full")) +print("multicut:", mg.get("multicut_uplift")) diff --git a/scripts/research/skyhook/runs/SKH_P_LOCAL_winner.py b/scripts/research/skyhook/runs/SKH_P_LOCAL_winner.py new file mode 100644 index 0000000..d915e04 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_LOCAL_winner.py @@ -0,0 +1,26 @@ +"""SKH_P_LOCAL winner: ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16 +(rest = V1). Beats V1 on DD (34% vs 49%), minHold (+0.81 vs +0.64), minFull (+0.83 vs +0.69), +and pushes clean-year(2025) uplift well over 0.02 -> robust_oos True (fully earns a slot).""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) +WIN = SkyhookParams(**{**V1, **dict(ptn_n=45, sl_atr=2.5, tp_atr=7.0, uscitalong=24, uscitashort=16)}) + +rep = sk.study("SKH_P_LOCAL_winner", WIN) +print(sk.fmt(rep)) +print("causality:", sk.causality(WIN, asset="BTC"), sk.causality(WIN, asset="ETH")) +mg = sk.marginal(WIN) +keys=("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge", + "robust_oos","multicut_persistent","clean_year_uplift","jackknife_min_uplift", + "cand_insample_sharpe","cand_full_sharpe","beta_to_tp01","alpha_ann") +print("marginal:", {k: mg.get(k) for k in keys}) +print("blend w25 uplift_hold:", mg.get("blends",{}).get("w25",{}).get("uplift_hold"), + "uplift_full:", mg.get("blends",{}).get("w25",{}).get("uplift_full")) +print("blend w50:", mg.get("blends",{}).get("w50")) +print("multicut:", mg.get("multicut_uplift")) +v=rep["verdict"] +print("\nVERDICT:", v) diff --git a/scripts/research/skyhook/runs/SKH_P_REGIME.py b/scripts/research/skyhook/runs/SKH_P_REGIME.py new file mode 100644 index 0000000..9f93cd7 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_REGIME.py @@ -0,0 +1,107 @@ +"""SKH_P_REGIME — regime-band sweep on the V1 base. + +Search bands: vola_lo in {20,30,35,45}, vola_hi in {88,95,100}, + vol_lo in {0,30,45,55}, vol_hi in {80,100}. +Find the combo that lifts min HOLD-OUT and is a PLATEAU (neighbors also good). +Compare to V1: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + -> minFull +0.69, minHold +0.64. +""" +from __future__ import annotations +import sys, itertools +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +ASSETS = ("BTC", "ETH") + +# V1 base (everything except the regime bands stays fixed) +BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0) + +VOLA_LO = [20, 30, 35, 45] +VOLA_HI = [88, 95, 100] +VOL_LO = [0, 30, 45, 55] +VOL_HI = [80, 100] + + +def mk(vlo, vhi, vol_lo, vol_hi): + return SkyhookParams(vola_lo=vlo, vola_hi=vhi, vol_lo=vol_lo, vol_hi=vol_hi, **BASE) + + +def eval_combo(vlo, vhi, vol_lo, vol_hi): + p = mk(vlo, vhi, vol_lo, vol_hi) + res = {} + for a in ASSETS: + r = sk.run_asset(a, p, sk.FEE_RT) + res[a] = r + min_full = min(res[a]["full"]["sharpe"] for a in ASSETS) + min_hold = min(res[a]["holdout"]["sharpe"] for a in ASSETS) + min_tr = min(res[a]["full"]["n_trades"] for a in ASSETS) + max_dd = max(res[a]["full"]["maxdd"] for a in ASSETS) + return dict(vlo=vlo, vhi=vhi, vol_lo=vol_lo, vol_hi=vol_hi, + min_full=min_full, min_hold=min_hold, min_tr=min_tr, max_dd=max_dd, + btc_hold=res["BTC"]["holdout"]["sharpe"], eth_hold=res["ETH"]["holdout"]["sharpe"], + btc_full=res["BTC"]["full"]["sharpe"], eth_full=res["ETH"]["full"]["sharpe"]) + + +def main(): + rows = [] + combos = list(itertools.product(VOLA_LO, VOLA_HI, VOL_LO, VOL_HI)) + print(f"Sweeping {len(combos)} band combos x 2 assets = {len(combos)*2} run_asset calls") + for vlo, vhi, vol_lo, vol_hi in combos: + rows.append(eval_combo(vlo, vhi, vol_lo, vol_hi)) + + # rank by min HOLD-OUT (subject to minFull>=0.5 and >=20 trades both assets) + valid = [r for r in rows if r["min_full"] >= 0.5 and r["min_tr"] >= 20] + valid.sort(key=lambda r: r["min_hold"], reverse=True) + + print("\n=== TOP 15 by min HOLD-OUT (minFull>=0.5, minTrades>=20) ===") + print(f"{'vlo':>4}{'vhi':>5}{'vol_lo':>7}{'vol_hi':>7} {'minF':>6}{'minH':>6}{'minTr':>6}{'maxDD':>7} {'btcH':>6}{'ethH':>6}") + for r in valid[:15]: + print(f"{r['vlo']:>4}{r['vhi']:>5}{r['vol_lo']:>7}{r['vol_hi']:>7} " + f"{r['min_full']:>6.2f}{r['min_hold']:>6.2f}{r['min_tr']:>6}{r['max_dd']*100:>6.0f}% " + f"{r['btc_hold']:>6.2f}{r['eth_hold']:>6.2f}") + + # full table sorted for plateau inspection (group by lo/hi neighbors) + rows_sorted = sorted(rows, key=lambda r: r["min_hold"], reverse=True) + print("\n=== ALL combos by min HOLD-OUT ===") + for r in rows_sorted: + flag = "" if (r["min_full"] >= 0.5 and r["min_tr"] >= 20) else " (low-full/trades)" + print(f" vlo={r['vlo']:>3} vhi={r['vhi']:>3} vol_lo={r['vol_lo']:>3} vol_hi={r['vol_hi']:>3} | " + f"minF={r['min_full']:+.2f} minH={r['min_hold']:+.2f} tr={r['min_tr']:>3} DD={r['max_dd']*100:.0f}%{flag}") + + if not valid: + print("\nNo valid combo (minFull>=0.5 & >=20 trades). Best raw:") + print(rows_sorted[0]) + return + + # plateau check: for the top combo, look at neighbors in the grid + top = valid[0] + print(f"\n=== WINNER: vlo={top['vlo']} vhi={top['vhi']} vol_lo={top['vol_lo']} vol_hi={top['vol_hi']} ===") + print(f" minFull={top['min_full']:+.2f} minHold={top['min_hold']:+.2f} minTr={top['min_tr']} maxDD={top['max_dd']*100:.0f}%") + + # neighbor plateau: same vol_lo/vol_hi, vary vola_lo/vola_hi to adjacent grid values + def find(vlo, vhi, vol_lo, vol_hi): + for r in rows: + if r["vlo"]==vlo and r["vhi"]==vhi and r["vol_lo"]==vol_lo and r["vol_hi"]==vol_hi: + return r + return None + print("\n Plateau neighbors (min HOLD-OUT):") + for vlo in VOLA_LO: + for vhi in VOLA_HI: + r = find(vlo, vhi, top['vol_lo'], top['vol_hi']) + if r: + mark = " <-- WIN" if (vlo==top['vlo'] and vhi==top['vhi']) else "" + print(f" vola_lo={vlo:>3} vola_hi={vhi:>3}: minH={r['min_hold']:+.2f} minF={r['min_full']:+.2f}{mark}") + + # final study + causality + marginal on the winner + p = mk(top['vlo'], top['vhi'], top['vol_lo'], top['vol_hi']) + print("\n=== STUDY (winner) ===") + rep = sk.study(f"SKH_P_REGIME_vlo{top['vlo']}_vhi{top['vhi']}_vollo{top['vol_lo']}_volhi{top['vol_hi']}", p) + print(sk.fmt(rep)) + print("\ncausality:", sk.causality(p)) + print("\nmarginal:", sk.marginal(p)) + print("\nas_json:", sk.as_json(rep)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/skyhook/runs/SKH_P_REGIME_plateau.py b/scripts/research/skyhook/runs/SKH_P_REGIME_plateau.py new file mode 100644 index 0000000..4bb6203 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_P_REGIME_plateau.py @@ -0,0 +1,46 @@ +"""SKH_P_REGIME_plateau — tight plateau probe around the sweep winner +vola_lo=20, vola_hi=88, vol_lo=55, vol_hi=80 (V1 base: ptn_n=55, sl_atr=2.5, tp_atr=6.0). + +Confirm neighbors in ALL 4 band dims are also good (no knife-edge). +""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import skyhooklib as sk +from src.strategies.skyhook import SkyhookParams + +ASSETS = ("BTC", "ETH") +BASE = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0) +WIN = dict(vola_lo=20, vola_hi=88, vol_lo=55, vol_hi=80) + + +def ev(**bands): + p = SkyhookParams(**{**BASE, **bands}) + res = {a: sk.run_asset(a, p, sk.FEE_RT) for a in ASSETS} + mf = min(res[a]["full"]["sharpe"] for a in ASSETS) + mh = min(res[a]["holdout"]["sharpe"] for a in ASSETS) + tr = min(res[a]["full"]["n_trades"] for a in ASSETS) + dd = max(res[a]["full"]["maxdd"] for a in ASSETS) + return mf, mh, tr, dd + + +def probe(dim, values): + print(f"\n-- perturb {dim} (others at winner) --") + for v in values: + bands = dict(WIN); bands[dim] = v + mf, mh, tr, dd = ev(**bands) + mark = " <-- WIN" if v == WIN[dim] else "" + print(f" {dim}={v:>4}: minF={mf:+.2f} minH={mh:+.2f} tr={tr:>3} DD={dd*100:.0f}%{mark}") + + +def main(): + mf, mh, tr, dd = ev(**WIN) + print(f"WINNER {WIN}: minF={mf:+.2f} minH={mh:+.2f} tr={tr} DD={dd*100:.0f}%") + probe("vola_lo", [15, 20, 25, 30]) + probe("vola_hi", [83, 85, 88, 90, 92]) + probe("vol_lo", [45, 50, 55, 60, 65]) + probe("vol_hi", [75, 78, 80, 82, 85]) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/skyhook/runs/SKH_R_EXPAND.py b/scripts/research/skyhook/runs/SKH_R_EXPAND.py new file mode 100644 index 0000000..999f7fd --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_R_EXPAND.py @@ -0,0 +1,306 @@ +"""SKH_R_EXPAND — REGIME variant: VOLATILITY-EXPANSION gate. + +Hypothesis (the brief: "enter when vol+volume regime AND breakout coincide"): +Instead of the Chande01 *cycle* band on ATR, define the regime as a genuine VOLATILITY +EXPANSION: trade only when ATR is RISING vs its own moving average (a vol breakout) AND +volume is elevated vs its own moving average. The intuition is that a Donchian breakout that +fires WHILE volatility is expanding on rising participation (volume) is more likely to be a +real move than one that fires inside a quiet/contracting regime (chop, mean-reversion). + +Regime definition (HTF, causal): + vol_expansion = ATR[i] >= k_atr * MA(ATR, w_atr) (ATR above its own MA -> rising) + volume_elev = volume[i] >= k_vol * MA(volume, w_vol) (participation elevated) + regime_ok = vol_expansion AND volume_elev +MA is a CAUSAL rolling mean (uses x[i-w+1..i] inclusive of the current, already-closed bar). +k_atr / k_vol are tunable multipliers (1.0 = "above MA"; >1 = "well above MA"). + +Pattern/composer/entry/exit reuse the V1 Skyhook building blocks unchanged: + ptn_n=55 Donchian, sl_atr=2.5, tp_atr=6.0, asymmetric time exits, max 1/day. + +Causality: every regime feature uses only x[0..i] (rolling MA, ATR ewm, donchian shift(1)), +INCLUSIVE of the current HTF bar — legit because at HTF close[i] the bar is fully known. The +HTF feature is merged BACKWARD onto LTF on the HTF-close timestamp (<= LTF close). We verify +the truncated-prefix guard ourselves 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.backtest.harness import backtest_signals +from src.strategies import skyhook as S +from src.strategies.skyhook import SkyhookParams, HTF_MIN + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +FEE = sk.FEE_RT + + +# --------------------------------------------------------------------------- +# Causal rolling MA (inclusive of current, already-closed bar). min_periods enforced. +# --------------------------------------------------------------------------- +def causal_ma(x: np.ndarray, win: int, min_periods: int | None = None) -> np.ndarray: + mp = win if min_periods is None else min_periods + return pd.Series(np.asarray(x, float)).rolling(win, min_periods=mp).mean().values + + +# --------------------------------------------------------------------------- +# HTF feature df: volatility-EXPANSION regime gate + Donchian pattern (V1 pattern reused). +# regime_ok = (ATR >= k_atr*MA(ATR,w_atr)) AND (volume >= k_vol*MA(volume,w_vol)) +# --------------------------------------------------------------------------- +def expand_htf_features(htf: pd.DataFrame, p: SkyhookParams, + w_atr: int, k_atr: float, + w_vol: int, k_vol: float) -> pd.DataFrame: + atr_htf = S.atr(htf, p.atr_win) + vol_htf = htf["volume"].values.astype(float) + + atr_ma = causal_ma(atr_htf, w_atr) + vol_ma = causal_ma(vol_htf, w_vol) + + # rising-vol = current ATR above k_atr * its own MA ; same for volume. + # NaN during warmup -> False (no trade until the regime is computable). + vol_expansion = np.where(np.isfinite(atr_ma) & (atr_ma > 0), atr_htf >= k_atr * atr_ma, False) + volume_elev = np.where(np.isfinite(vol_ma) & (vol_ma > 0), vol_htf >= k_vol * vol_ma, False) + regime_ok = vol_expansion & volume_elev + + ptn_long, ptn_short = S.donchian_breakout(htf, p.ptn_n) + 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, + # store the ratios for diagnostics (not used downstream) + "buz_vola": np.where(np.isfinite(atr_ma) & (atr_ma > 0), atr_htf / atr_ma, np.nan), + "buz_volume": np.where(np.isfinite(vol_ma) & (vol_ma > 0), vol_htf / vol_ma, np.nan), + "comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool), + }) + + +def expand_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams, + w_atr, k_atr, w_vol, k_vol) -> list: + """Same entry/exit machinery as S.skyhook_entries, regime from expansion features.""" + feat = expand_htf_features(htf, p, w_atr, k_atr, w_vol, k_vol) + m = S.merge_htf_to_ltf(ltf, feat) + + c = m["close"].values.astype(float) + a = S.atr(m, p.ltf_atr_win) + comp_long = np.nan_to_num(m["comp_long"].values).astype(bool) + comp_short = np.nan_to_num(m["comp_short"].values).astype(bool) + days = pd.to_datetime(m["datetime"]).dt.floor("D").values + + entries: list = [None] * len(m) + count_today: dict = {} + for i in range(len(m)): + if not np.isfinite(a[i]) or a[i] <= 0: + continue + day = days[i] + if count_today.get(day, 0) >= p.max_per_day: + continue + if comp_long[i]: + direction, mb = 1, p.uscitalong + elif comp_short[i]: + direction, mb = -1, p.uscitashort + else: + continue + if p.exit_mode == "atr": + sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i] + else: + sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i] + if direction == 1: + sl, tp = c[i] - sl_off, c[i] + tp_off + else: + sl, tp = c[i] + sl_off, c[i] - tp_off + entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)} + count_today[day] = count_today.get(day, 0) + 1 + return entries + + +# --------------------------------------------------------------------------- +# Eval helpers +# --------------------------------------------------------------------------- +def _split(eq, idx, mask): + e = eq[mask] + if len(e) < 5: + return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e))) + r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)] + ix = idx[mask] + dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400 + bpy = 86400 * 365.25 / dt + sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 + pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk)) + return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e))) + + +def eval_cfg(cfg, p): + out = {} + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = expand_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = _split(eq, idx, hmask) + out[a] = dict(full=full, hold=hold, _eq=eq, _idx=idx) + return out + + +def summarize(tag, res): + mf = min(res[a]["full"]["sharpe"] for a in res) + mh = min(res[a]["hold"]["sharpe"] for a in res) + mt = min(res[a]["full"]["n_trades"] for a in res) + mdd = max(res[a]["full"]["maxdd"] for a in res) + print(f" [{tag}] minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}%" + f" | BTC F{res['BTC']['full']['sharpe']:+.2f}/H{res['BTC']['hold']['sharpe']:+.2f} n{res['BTC']['full']['n_trades']}" + f" ETH F{res['ETH']['full']['sharpe']:+.2f}/H{res['ETH']['hold']['sharpe']:+.2f} n{res['ETH']['full']['n_trades']}") + return dict(minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, res=res) + + +def v1_like_params(**kw): + base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0) # V1 pattern/exit + base.update(kw) + return SkyhookParams(**base) + + +# --------------------------------------------------------------------------- +# Causality self-check on the structural variant (truncated-prefix guard) +# --------------------------------------------------------------------------- +def check_causality(cfg, p, asset="BTC", tail=150): + ltf, htf = sk.frames(asset) + full = expand_entries(ltf, htf, p, **cfg) + n = len(ltf); bad = 0; checked = 0 + for frac in (0.80, 0.92): + cut = int(n * frac) + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + sub = expand_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, **cfg) + for i in range(max(0, cut - tail), cut): + checked += 1 + a, b = full[i], sub[i] + if (a is None) != (b is None): + bad += 1 + elif a is not None and (a["dir"] != b["dir"] + or abs(a["sl"] - b["sl"]) > 1e-6 + or abs(a["tp"] - b["tp"]) > 1e-6): + bad += 1 + return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) + + +if __name__ == "__main__": + print("=== SKH_R_EXPAND: volatility-EXPANSION regime (ATR rising vs its MA + volume elevated) ===\n") + + # --- V1 reference (Chande01 regime) --- + print("--- V1 reference (Chande01 regime, ptn_n=55 sl2.5 tp6 vola35-95 vol0) ---") + v1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + v1res = {} + for a in ("BTC", "ETH"): + r = sk.run_asset(a, v1, FEE) + v1res[a] = dict(full=dict(sharpe=r["full"]["sharpe"], n_trades=r["full"]["n_trades"], + maxdd=r["full"]["maxdd"]), hold=dict(sharpe=r["holdout"]["sharpe"])) + v1_minFull = min(v1res[a]['full']['sharpe'] for a in v1res) + v1_minHold = min(v1res[a]['hold']['sharpe'] for a in v1res) + v1_maxDD = max(v1res[a]['full']['maxdd'] for a in v1res) + print(f" V1 minFull={v1_minFull:+.2f} minHold={v1_minHold:+.2f} maxDD={v1_maxDD*100:.0f}%" + f" | BTC F{v1res['BTC']['full']['sharpe']:+.2f}/H{v1res['BTC']['hold']['sharpe']:+.2f}" + f" ETH F{v1res['ETH']['full']['sharpe']:+.2f}/H{v1res['ETH']['hold']['sharpe']:+.2f}\n") + + p = v1_like_params() # same pattern/exit as V1; only regime changes + + # --- Sweep: expansion-MA windows + multipliers --- + # k=1.0 -> "ATR above its own MA" (mild rising). k>1 -> stronger expansion (fewer trades). + # w_atr/w_vol: lookback for the MA (HTF bars; 690min each). vol elevated mirrored on volume. + print("--- volatility-EXPANSION sweep ---") + cfgs = { + # (w_atr,k_atr) , (w_vol,k_vol) + "atr20k1.0_vol20k1.0": dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=1.00), + "atr20k1.0_vol20k1.2": dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=1.20), + "atr20k1.1_vol20k1.0": dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.00), + "atr20k1.1_vol20k1.2": dict(w_atr=20, k_atr=1.10, w_vol=20, k_vol=1.20), + "atr10k1.0_vol10k1.0": dict(w_atr=10, k_atr=1.00, w_vol=10, k_vol=1.00), + "atr10k1.1_vol10k1.2": dict(w_atr=10, k_atr=1.10, w_vol=10, k_vol=1.20), + "atr30k1.0_vol30k1.0": dict(w_atr=30, k_atr=1.00, w_vol=30, k_vol=1.00), + "atr20k1.0_volOFF": dict(w_atr=20, k_atr=1.00, w_vol=20, k_vol=0.00), # vol gate off (k=0 always true) + "atr20k1.2_vol20k1.0": dict(w_atr=20, k_atr=1.20, w_vol=20, k_vol=1.00), + } + results = {} + for tag, cfg in cfgs.items(): + results[tag] = (cfg, summarize(tag, eval_cfg(cfg, p))) + + # --- Pick winner by minHold (subject to minFull>=0.5, minTr>=20) --- + elig = {t: v for t, (c, v) in results.items() if v["minFull"] >= 0.5 and v["minTr"] >= 20} + print(f"\n--- eligible (minFull>=0.5, minTr>=20): {list(elig.keys())} ---") + if elig: + win_tag = max(elig, key=lambda t: elig[t]["minHold"]) + else: + win_tag = max(results, key=lambda t: results[t][1]["minHold"]) + win_cfg, win_v = results[win_tag][0], results[win_tag][1] + print(f"\n*** WINNER = {win_tag} minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}" + f" minTr={win_v['minTr']} maxDD={win_v['maxDD']*100:.0f}% ***") + print(f" cfg = {win_cfg}") + + # --- Causality on winner (BOTH assets) --- + caus = check_causality(win_cfg, p, "BTC") + caus_eth = check_causality(win_cfg, p, "ETH") + print(f"\ncausality(BTC) = {caus}") + print(f"causality(ETH) = {caus_eth}") + + # --- Fee sweep on winner (both assets, FULL) --- + print("\n--- fee sweep on winner (FULL sharpe) ---") + fee_ok_all = True + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent_cache = expand_entries(ltf, htf, p, **win_cfg) + row = [] + for f in (0.0, 0.001, 0.002, 0.003): + m = backtest_signals(ltf, ent_cache, fee_rt=f, leverage=1.0, asset=a, tf="230m") + row.append((f, round(m.sharpe, 3))) + sh030 = dict(row)[0.003] + fee_ok_all = fee_ok_all and (sh030 > 0) + print(f" {a}: " + " ".join(f"{f*100:.2f}%={s:+.2f}" for f, s in row)) + print(f" fee_survives 0.30%RT (both): {fee_ok_all}") + + # --- Per-year on winner --- + print("\n--- per-year (winner) ---") + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = expand_entries(ltf, htf, p, **win_cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + yr = " ".join(f"{y}:{v*100:+.0f}%" for y, v in m.yearly.items()) + print(f" {a}: {yr}") + + # --- Marginal vs TP01 on winner --- + def daily_series(a, p, cfg): + ltf, htf = sk.frames(a) + ent = expand_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + return s.resample("1D").last().ffill().pct_change().dropna() + + import altlib as al + sb = daily_series("BTC", p, win_cfg); se = daily_series("ETH", p, win_cfg) + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + cand_daily = 0.5 * J["BTC"] + 0.5 * J["ETH"] + marg = al.marginal_vs_tp01(cand_daily) + print("\n--- marginal vs TP01 (winner) ---") + print(f" corr_full={marg.get('corr_full')} corr_hold={marg.get('corr_hold')}") + print(f" blend w25 uplift_hold={marg.get('blends',{}).get('w25',{}).get('uplift_hold')}") + print(f" marginal_verdict={marg.get('marginal_verdict')} robust_oos={marg.get('robust_oos')}") + print(f" has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')}") + print(f" clean_year_uplift={marg.get('clean_year_uplift')} jackknife_min_uplift={marg.get('jackknife_min_uplift')}") + print(f" multicut_persistent={marg.get('multicut_persistent')}") + + # --- Final verdict echo --- + print("\n=== SUMMARY ===") + print(f"V1: minFull={v1_minFull:+.2f} minHold={v1_minHold:+.2f} maxDD={v1_maxDD*100:.0f}%") + print(f"EXPAND {win_tag}: minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}" + f" maxDD={win_v['maxDD']*100:.0f}% causalityOK={caus['ok'] and caus_eth['ok']} feeOK={fee_ok_all}") + beats = (win_v['minHold'] > v1_minHold) and win_v['minFull'] >= 0.5 and fee_ok_all + print(f"BEATS V1 HOLD-OUT: {beats}") diff --git a/scripts/research/skyhook/runs/SKH_R_EXPAND_study.py b/scripts/research/skyhook/runs/SKH_R_EXPAND_study.py new file mode 100644 index 0000000..bc17dd8 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_R_EXPAND_study.py @@ -0,0 +1,72 @@ +"""SKH_R_EXPAND_study — final study() on the two best volatility-expansion configs. + +We use the project's HONEST study() harness. Because the expansion regime is a STRUCTURAL +change (not expressible via SkyhookParams bands), we monkeypatch htf_features INSIDE +skyhooklib's namespace to our expansion-features for the duration of each study, so study() +runs the exact same leak-free FULL+HOLDOUT+fee-sweep+per-year machinery on our entries. + +This is safe: we only swap the feature builder (regime def); pattern/composer/entry/exit and +all the eval code are unchanged. We restore the original after each study. +""" +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 + +# import our feature builder +from SKH_R_EXPAND import expand_htf_features + +ORIG_FEAT = S.htf_features + + +def make_patched_features(cfg): + def _feat(htf, p): + return expand_htf_features(htf, p, **cfg) + return _feat + + +def study_expand(name, p, cfg): + """Run sk.study with htf_features patched to the expansion regime defined by cfg.""" + patched = make_patched_features(cfg) + # skyhook_entries calls skyhook.htf_features via the module-level name S.htf_features. + S.htf_features = patched + 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 + + +if __name__ == "__main__": + p = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0) + + configs = { + # best DD-cut + above-V1 full, balanced volume gate (maxDD 34%) + "EXP_atr20vol20": dict(w_atr=20, k_atr=1.0, w_vol=20, k_vol=1.0), + # best hold-out / no volume gate (minFull 0.81, DD 40%) + "EXP_atr20volOFF": dict(w_atr=20, k_atr=1.0, w_vol=20, k_vol=0.0), + } + + for name, cfg in configs.items(): + print(f"\n########## {name} cfg={cfg} ##########") + rep, (caus, caus_eth), marg = study_expand(name, p, cfg) + print(sk.fmt(rep)) + print(f"causality BTC={caus} ETH={caus_eth}") + print(f"marginal: verdict={marg.get('marginal_verdict')} corr_full={marg.get('corr_full')}" + f" blend_w25_uplift_hold={marg.get('blends',{}).get('w25',{}).get('uplift_hold')}") + print(f" has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')}" + f" robust_oos={marg.get('robust_oos')} clean_year_uplift={marg.get('clean_year_uplift')}" + f" multicut_persistent={marg.get('multicut_persistent')}") + v = rep["verdict"] + print(f"VERDICT[{name}]: grade={v['grade']} minFull={v['min_asset_full_sharpe']}" + f" minHold={v['min_asset_holdout_sharpe']} minTrades={v['min_trades']} feeOK={v['fee_survives']}") diff --git a/scripts/research/skyhook/runs/SKH_R_PCTL.py b/scripts/research/skyhook/runs/SKH_R_PCTL.py new file mode 100644 index 0000000..4ee7d99 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_R_PCTL.py @@ -0,0 +1,308 @@ +"""SKH_R_PCTL — REGIME variant: replace Chande01 regime with CAUSAL expanding/rolling +PERCENTILE-RANK of ATR and volume (0-1), gate on rank bands. + +Hypothesis: Chande01 measures the *direction/momentum* of the vol/volume cycle (rising vs +falling), mapped to 0-100. A percentile-RANK instead measures *where the current level sits* +within its own history (is ATR/volume HIGH or LOW relative to the past). This is a more +natural "regime" definition: trade only when vol/volume is in a chosen part of its own +distribution. We test expanding (full history) and rolling-window percentile ranks. + +Causality: rank[i] uses only x[0..i] (expanding) or x[i-w+1..i] (rolling), INCLUSIVE of the +current bar — this is legitimate because at HTF close[i] the bar's ATR/volume is known. The +HTF feature is then merged backward to LTF on HTF-close timestamp (<= LTF close). We verify +the truncated-prefix guard ourselves. + +Pattern/composer/entry/exit reuse the V1 Skyhook building blocks unchanged. +""" +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.backtest.harness import backtest_signals +from src.strategies import skyhook as S +from src.strategies.skyhook import SkyhookParams, HTF_MIN, LTF_MIN + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +FEE = sk.FEE_RT + + +# --------------------------------------------------------------------------- +# Causal percentile-rank (0-1). Fraction of the window strictly < current value, +# computed inclusive of the current bar (legit: bar is closed). min_periods enforced. +# --------------------------------------------------------------------------- +def pctl_rank(x: np.ndarray, win: int | None, min_periods: int = 30) -> np.ndarray: + """Causal percentile rank in [0,1]. win=None -> EXPANDING; else rolling window. + rank[i] = (#{x[j] < x[i]} + 0.5*#{x[j]==x[i]}) / count over the (expanding/rolling) window.""" + x = np.asarray(x, float) + s = pd.Series(x) + if win is None: + # expanding rank: pandas .expanding().rank(pct=True) gives rank/count INCLUSIVE of i, + # which counts <= (so the current bar's own value is included). Use 'average' to break ties. + r = s.expanding(min_periods=min_periods).rank(pct=True) + else: + r = s.rolling(win, min_periods=min(min_periods, win)).rank(pct=True) + return r.values # NaN until min_periods reached + + +# --------------------------------------------------------------------------- +# HTF feature df with percentile-rank regime gate + Donchian pattern (V1 pattern reused). +# --------------------------------------------------------------------------- +def pctl_htf_features(htf: pd.DataFrame, p: SkyhookParams, + vola_win: int | None, vol_win: int | None, + vola_lo: float, vola_hi: float, + vol_lo: float, vol_hi: float) -> pd.DataFrame: + """Regime via CAUSAL percentile-rank (0-1) of ATR and volume; pattern via Donchian. + Bands here are in [0,1] (percentile space), NOT 0-100 like Chande01.""" + atr_htf = S.atr(htf, p.atr_win) + vola_rank = pctl_rank(atr_htf, vola_win) + vol_rank = pctl_rank(htf["volume"].values, vol_win) + ptn_long, ptn_short = S.donchian_breakout(htf, p.ptn_n) + + # regime_ok requires a valid (non-NaN) rank in band; NaN (warmup) -> False + vr_ok = np.where(np.isfinite(vola_rank), (vola_rank >= vola_lo) & (vola_rank <= vola_hi), False) + vol_ok = np.where(np.isfinite(vol_rank), (vol_rank >= vol_lo) & (vol_rank <= vol_hi), False) + regime_ok = vr_ok & vol_ok + + 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": vola_rank, "buz_volume": vol_rank, + "comp_long": comp_long.astype(bool), "comp_short": comp_short.astype(bool), + }) + + +def pctl_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams, + vola_win, vol_win, vola_lo, vola_hi, vol_lo, vol_hi) -> list: + """Same entry/exit machinery as S.skyhook_entries, but regime from pctl features.""" + feat = pctl_htf_features(htf, p, vola_win, vol_win, vola_lo, vola_hi, vol_lo, vol_hi) + m = S.merge_htf_to_ltf(ltf, feat) + + c = m["close"].values.astype(float) + a = S.atr(m, p.ltf_atr_win) + comp_long = np.nan_to_num(m["comp_long"].values).astype(bool) + comp_short = np.nan_to_num(m["comp_short"].values).astype(bool) + days = pd.to_datetime(m["datetime"]).dt.floor("D").values + + entries: list = [None] * len(m) + count_today: dict = {} + for i in range(len(m)): + if not np.isfinite(a[i]) or a[i] <= 0: + continue + day = days[i] + if count_today.get(day, 0) >= p.max_per_day: + continue + if comp_long[i]: + direction, mb = 1, p.uscitalong + elif comp_short[i]: + direction, mb = -1, p.uscitashort + else: + continue + if p.exit_mode == "atr": + sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i] + else: + sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i] + if direction == 1: + sl, tp = c[i] - sl_off, c[i] + tp_off + else: + sl, tp = c[i] + sl_off, c[i] - tp_off + entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)} + count_today[day] = count_today.get(day, 0) + 1 + return entries + + +# --------------------------------------------------------------------------- +# Eval helpers +# --------------------------------------------------------------------------- +def _split(eq, idx, mask): + e = eq[mask] + if len(e) < 5: + return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e))) + r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)] + ix = idx[mask] + dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400 + bpy = 86400 * 365.25 / dt + sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 + pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk)) + return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e))) + + +def eval_cfg(cfg, p): + """Run both assets; return dict per asset with full+holdout.""" + out = {} + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = pctl_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = _split(eq, idx, hmask) + out[a] = dict(full=full, hold=hold, _eq=eq, _idx=idx) + return out + + +def summarize(tag, res): + mf = min(res[a]["full"]["sharpe"] for a in res) + mh = min(res[a]["hold"]["sharpe"] for a in res) + mt = min(res[a]["full"]["n_trades"] for a in res) + mdd = max(res[a]["full"]["maxdd"] for a in res) + print(f" [{tag}] minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}%" + f" | BTC F{res['BTC']['full']['sharpe']:+.2f}/H{res['BTC']['hold']['sharpe']:+.2f}" + f" ETH F{res['ETH']['full']['sharpe']:+.2f}/H{res['ETH']['hold']['sharpe']:+.2f}") + return dict(minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, res=res) + + +# Build a SkyhookParams matching V1 non-regime knobs (pattern + exits) +def v1_like_params(**kw): + base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0) # V1 pattern/exit + base.update(kw) + return SkyhookParams(**base) + + +# --------------------------------------------------------------------------- +# Causality self-check on the structural variant +# --------------------------------------------------------------------------- +def check_causality(cfg, p, asset="BTC", tail=150): + ltf, htf = sk.frames(asset) + full = pctl_entries(ltf, htf, p, **cfg) + n = len(ltf); bad = 0; checked = 0 + for frac in (0.80, 0.92): + cut = int(n * frac) + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + sub = pctl_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, **cfg) + for i in range(max(0, cut - tail), cut): + checked += 1 + a, b = full[i], sub[i] + if (a is None) != (b is None): + bad += 1 + elif a is not None and (a["dir"] != b["dir"] + or abs(a["sl"] - b["sl"]) > 1e-6 + or abs(a["tp"] - b["tp"]) > 1e-6): + bad += 1 + return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) + + +if __name__ == "__main__": + print("=== SKH_R_PCTL: percentile-rank regime ===\n") + + # --- V1 reference for comparison (Chande01 regime) --- + print("--- V1 reference (Chande01 regime, ptn_n=55 sl2.5 tp6 vola35-95 vol0) ---") + v1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) + v1res = {} + for a in ("BTC", "ETH"): + r = sk.run_asset(a, v1, FEE) + v1res[a] = dict(full=dict(sharpe=r["full"]["sharpe"], n_trades=r["full"]["n_trades"], + maxdd=r["full"]["maxdd"]), + hold=dict(sharpe=r["holdout"]["sharpe"])) + print(f" V1 minFull={min(v1res[a]['full']['sharpe'] for a in v1res):+.2f}" + f" minHold={min(v1res[a]['hold']['sharpe'] for a in v1res):+.2f}" + f" maxDD={max(v1res[a]['full']['maxdd'] for a in v1res)*100:.0f}%" + f" | BTC F{v1res['BTC']['full']['sharpe']:+.2f}/H{v1res['BTC']['hold']['sharpe']:+.2f}" + f" ETH F{v1res['ETH']['full']['sharpe']:+.2f}/H{v1res['ETH']['hold']['sharpe']:+.2f}\n") + + p = v1_like_params() # same pattern/exit as V1; only regime changes + + # --- Sweep: percentile-rank regime bands --- + # Two regime intuitions to test: + # (A) HIGH-vol/HIGH-volume regime (breakout-friendly): ranks in upper band. + # (B) MID regime (avoid blow-off + dead): ranks in a middle band. + # vol_lo=0 means "no lower bound on volume" (mirror V1's vol_lo=0). + print("--- EXPANDING percentile-rank sweep ---") + cfgs = { + # vola band, vol band, both expanding (win=None) + "exp_volaHi_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0), + "exp_volaMid_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.30, vola_hi=0.80, vol_lo=0.0, vol_hi=1.0), + "exp_volaHi_volHi": dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.40, vol_hi=1.0), + "exp_volaLo_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.0, vol_hi=1.0), + "exp_volaWide_vol0": dict(vola_win=None, vol_win=None, vola_lo=0.20, vola_hi=1.0, vol_lo=0.0, vol_hi=1.0), + } + results = {} + for tag, cfg in cfgs.items(): + results[tag] = (cfg, summarize(tag, eval_cfg(cfg, p))) + + print("\n--- ROLLING percentile-rank sweep (win=60 HTF bars ~ recent regime) ---") + cfgs_roll = { + "roll60_volaHi_vol0": dict(vola_win=60, vol_win=60, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0), + "roll60_volaMid_vol0": dict(vola_win=60, vol_win=60, vola_lo=0.30, vola_hi=0.80, vol_lo=0.0, vol_hi=1.0), + "roll120_volaHi_vol0": dict(vola_win=120, vol_win=120, vola_lo=0.35, vola_hi=0.95, vol_lo=0.0, vol_hi=1.0), + "roll60_volaHi_volHi": dict(vola_win=60, vol_win=60, vola_lo=0.35, vola_hi=0.95, vol_lo=0.40, vol_hi=1.0), + } + for tag, cfg in cfgs_roll.items(): + results[tag] = (cfg, summarize(tag, eval_cfg(cfg, p))) + + # --- Pick winner by minHold (subject to minFull>=0.5, minTr>=20) --- + elig = {t: v for t, (c, v) in results.items() + if v["minFull"] >= 0.5 and v["minTr"] >= 20} + print(f"\n--- eligible (minFull>=0.5, minTr>=20): {list(elig.keys())} ---") + if elig: + win_tag = max(elig, key=lambda t: elig[t]["minHold"]) + else: + # fall back to best minHold overall to report honestly + win_tag = max(results, key=lambda t: results[t][1]["minHold"]) + win_cfg, win_v = results[win_tag][0], results[win_tag][1] + print(f"\n*** WINNER = {win_tag} minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}" + f" minTr={win_v['minTr']} maxDD={win_v['maxDD']*100:.0f}% ***") + print(f" cfg = {win_cfg}") + + # --- Causality on winner --- + caus = check_causality(win_cfg, p, "BTC") + print(f"\ncausality(BTC) = {caus}") + caus_eth = check_causality(win_cfg, p, "ETH") + print(f"causality(ETH) = {caus_eth}") + + # --- Fee sweep on winner (both assets, FULL) --- + print("\n--- fee sweep on winner (FULL sharpe) ---") + fee_ok_all = True + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent_cache = pctl_entries(ltf, htf, p, **win_cfg) + row = [] + for f in (0.0, 0.001, 0.002, 0.003): + m = backtest_signals(ltf, ent_cache, fee_rt=f, leverage=1.0, asset=a, tf="230m") + row.append((f, round(m.sharpe, 3))) + sh030 = dict(row)[0.003] + fee_ok_all = fee_ok_all and (sh030 > 0) + print(f" {a}: " + " ".join(f"{f*100:.2f}%={s:+.2f}" for f, s in row)) + print(f" fee_survives 0.30%RT (both): {fee_ok_all}") + + # --- Marginal vs TP01 on winner --- + # Build a daily 50/50 series the same way skyhooklib does, but with our entries. + def daily_series(a, p, cfg): + ltf, htf = sk.frames(a) + ent = pctl_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + return s.resample("1D").last().ffill().pct_change().dropna() + + import altlib as al + sb = daily_series("BTC", p, win_cfg); se = daily_series("ETH", p, win_cfg) + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + cand_daily = 0.5 * J["BTC"] + 0.5 * J["ETH"] + marg = al.marginal_vs_tp01(cand_daily) + print("\n--- marginal vs TP01 (winner) ---") + print(f" corr_full={marg.get('corr_full')} corr_hold={marg.get('corr_hold')}") + print(f" blend w25 uplift_hold={marg.get('blends',{}).get('w25',{}).get('uplift_hold')}") + print(f" marginal_verdict={marg.get('marginal_verdict')} robust_oos={marg.get('robust_oos')}") + print(f" has_insample_edge={marg.get('has_insample_edge')} is_hedge={marg.get('is_hedge')}") + print(f" clean_year_uplift={marg.get('clean_year_uplift')} jackknife_min_uplift={marg.get('jackknife_min_uplift')}") + + # --- Final verdict echo --- + print("\n=== SUMMARY ===") + print(f"V1: minFull={min(v1res[a]['full']['sharpe'] for a in v1res):+.2f}" + f" minHold={min(v1res[a]['hold']['sharpe'] for a in v1res):+.2f}" + f" maxDD={max(v1res[a]['full']['maxdd'] for a in v1res)*100:.0f}%") + print(f"PCTL {win_tag}: minFull={win_v['minFull']:+.2f} minHold={win_v['minHold']:+.2f}" + f" maxDD={win_v['maxDD']*100:.0f}% causalityOK={caus['ok'] and caus_eth['ok']} feeOK={fee_ok_all}") diff --git a/scripts/research/skyhook/runs/SKH_R_PCTL_final.py b/scripts/research/skyhook/runs/SKH_R_PCTL_final.py new file mode 100644 index 0000000..e781410 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_R_PCTL_final.py @@ -0,0 +1,102 @@ +"""SKH_R_PCTL final: verify top configs with sk.study + marginal, refine for minFull/DD.""" +from __future__ import annotations +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +import numpy as np, pandas as pd +import skyhooklib as sk +from src.backtest.harness import backtest_signals +from src.strategies import skyhook as S +from src.strategies.skyhook import SkyhookParams, HTF_MIN + +# import the structural builder from the sweep script +import importlib.util +spec = importlib.util.spec_from_file_location( + "skr", "/opt/docker/PythagorasGoal/scripts/research/skyhook/runs/SKH_R_PCTL.py") +skr = importlib.util.module_from_spec(spec) +# avoid running its __main__ +import builtins +_orig_name = "__main__" +spec.loader.exec_module(skr) # defines functions; __main__ guard prevents the sweep + +HOLDOUT = sk.HOLDOUT +FEE = sk.FEE_RT + + +def study_struct(name, cfg, p): + """sk.study-equivalent for our structural variant: FULL+HOLD+fee-sweep+per-year BOTH assets.""" + per_asset = {} + fee_ok_all = True + for a in ("BTC", "ETH"): + ltf, htf = sk.frames(a) + ent = skr.pctl_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), ret=round(m.net_return, 4), maxdd=round(m.max_dd, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = skr._split(eq, idx, hmask) + sweep = {} + for f in (0.0, 0.001, 0.002, 0.003): + mf = backtest_signals(ltf, ent, fee_rt=f, leverage=1.0, asset=a, tf="230m") + sweep[f"{f*100:.2f}%"] = round(mf.sharpe, 3) + fee_ok_all = fee_ok_all and (sweep["0.30%"] > 0) + per_asset[a] = dict(full=full, hold=hold, yearly={int(y): round(v, 4) for y, v in m.yearly.items()}, + fee_sweep=sweep) + mf = min(per_asset[a]["full"]["sharpe"] for a in per_asset) + mh = min(per_asset[a]["hold"]["sharpe"] for a in per_asset) + mt = min(per_asset[a]["full"]["n_trades"] for a in per_asset) + mdd = max(per_asset[a]["full"]["maxdd"] for a in per_asset) + grade = "PASS" if (mt >= 20 and mf >= 0.5 and mh >= 0.2 and fee_ok_all) else \ + ("WEAK" if (mt >= 20 and mf >= 0.3 and mh >= 0.0) else "FAIL") + print(f"\n=== {name} -> {grade} (minFull={mf:+.2f} minHold={mh:+.2f} minTr={mt} maxDD={mdd*100:.0f}% feeOK={fee_ok_all})") + for a in per_asset: + pa = per_asset[a] + yr = " ".join(f"{y}:{r*100:+.0f}%" for y, r in pa["yearly"].items()) + print(f" {a}: FULL Sh={pa['full']['sharpe']:+.2f} ret={pa['full']['ret']*100:+.0f}% DD={pa['full']['maxdd']*100:.0f}%" + f" n={pa['full']['n_trades']} wr={pa['full']['win_rate']:.0f}% | HOLD Sh={pa['hold']['sharpe']:+.2f} ret={pa['hold']['ret']*100:+.0f}%") + print(f" fee: " + " ".join(f"{k}={v:+.2f}" for k, v in pa["fee_sweep"].items())) + print(f" yr: {yr}") + return dict(grade=grade, minFull=mf, minHold=mh, minTr=mt, maxDD=mdd, fee_ok=fee_ok_all, per_asset=per_asset) + + +def marginal_struct(cfg, p): + import altlib as al + def daily(a): + ltf, htf = sk.frames(a) + ent = skr.pctl_entries(ltf, htf, p, **cfg) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + return s.resample("1D").last().ffill().pct_change().dropna() + sb, se = daily("BTC"), daily("ETH") + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + cand = 0.5 * J["BTC"] + 0.5 * J["ETH"] + return al.marginal_vs_tp01(cand) + + +if __name__ == "__main__": + p = skr.v1_like_params() + + # Candidate A: best minHold (exp_volaHi_volHi) -- minFull 0.53 + cfgA = dict(vola_win=None, vol_win=None, vola_lo=0.35, vola_hi=0.95, vol_lo=0.40, vol_hi=1.0) + # Candidate B: best minFull + lower DD (exp_volaLo_vol0) -- minFull 0.70, DD 39% + cfgB = dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.0, vol_hi=1.0) + + # Refinements to lift minFull on A while keeping hold-out: tighten vola band / add small vol floor + cfgC = dict(vola_win=None, vol_win=None, vola_lo=0.10, vola_hi=0.80, vol_lo=0.30, vol_hi=1.0) + # B + modest vol floor to keep DD low but lift hold + cfgD = dict(vola_win=None, vol_win=None, vola_lo=0.0, vola_hi=0.70, vol_lo=0.30, vol_hi=1.0) + + rA = study_struct("PCTL-A exp_volaHi_volHi", cfgA, p) + rB = study_struct("PCTL-B exp_volaLo_vol0", cfgB, p) + rC = study_struct("PCTL-C exp_volaLoMid_volFloor", cfgC, p) + rD = study_struct("PCTL-D exp_volaLo_volFloor", cfgD, p) + + print("\n\n##### MARGINAL vs TP01 #####") + for tag, cfg, r in [("A", cfgA, rA), ("B", cfgB, rB), ("C", cfgC, rC), ("D", cfgD, rD)]: + mg = marginal_struct(cfg, p) + print(f"[{tag}] grade={r['grade']} minFull={r['minFull']:+.2f} minHold={r['minHold']:+.2f} DD={r['maxDD']*100:.0f}%" + f" | corr_full={mg.get('corr_full')} upliftHold={mg.get('blends',{}).get('w25',{}).get('uplift_hold')}" + f" verdict={mg.get('marginal_verdict')} robust_oos={mg.get('robust_oos')}" + f" insample_edge={mg.get('has_insample_edge')} hedge={mg.get('is_hedge')}" + f" cleanYr={mg.get('clean_year_uplift')}") diff --git a/scripts/research/skyhook/runs/SKH_R_RV.py b/scripts/research/skyhook/runs/SKH_R_RV.py new file mode 100644 index 0000000..6e0b306 --- /dev/null +++ b/scripts/research/skyhook/runs/SKH_R_RV.py @@ -0,0 +1,281 @@ +"""SKH_R_RV — REGIME family: define BuzVola from REALIZED VOL (rolling std of HTF log-returns, +annualized) instead of ATR, then Chande-normalize. Question: does a returns-based vol regime +gate better OOS than the ATR-based one? + +Structural variant: we rebuild htf_features ourselves, swapping ONLY the BuzVola source from +chande01(atr) to chande01(realized_vol). Everything else (BuzVolume, Donchian pattern, composer, +entries, exits) is IDENTICAL to the engine so the comparison is clean. Causal-only: realized vol +uses log-returns up to the HTF close; chande01 is causal rolling; donchian uses shift(1); the +HTF->LTF merge is backward on HTF close. We verify causality with a truncated-prefix guard. + +V1 reference to beat: SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, +vol_lo=0.0) -> minFull +0.69, minHold +0.64 (BTC .64/ETH .64), fee-safe to 0.30%RT, DD ~40-49%. +""" +from __future__ import annotations + +import sys +sys.path.insert(0, "/opt/docker/PythagorasGoal") +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/skyhook") +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") + +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, LTF_MIN +from src.backtest.harness import backtest_signals + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +FEE = sk.FEE_RT + + +# --------------------------------------------------------------------------- +# Realized-vol BuzVola: rolling std of HTF LOG-returns, annualized, Chande-normalized. +# rv_win = lookback bars for the realized-vol estimate. +# --------------------------------------------------------------------------- +def realized_vol(htf: pd.DataFrame, rv_win: int) -> np.ndarray: + c = htf["close"].values.astype(float) + logret = np.zeros_like(c) + logret[1:] = np.log(c[1:] / c[:-1]) + # annualization factor: bars per year at 690 min + bars_per_year = 365.25 * 24 * 60 / HTF_MIN + rv = pd.Series(logret).rolling(rv_win, min_periods=rv_win).std().values * np.sqrt(bars_per_year) + return rv + + +def htf_features_rv(htf: pd.DataFrame, p: SkyhookParams, rv_win: int) -> pd.DataFrame: + """Same as S.htf_features but BuzVola = chande01(realized_vol) instead of chande01(atr).""" + rv = realized_vol(htf, rv_win) + buz_vola = S.chande01(rv, p.n_vola) + buz_volume = S.chande01(htf["volume"].values, p.n_volume) + ptn_long, ptn_short = S.donchian_breakout(htf, p.ptn_n) + 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), + }) + + +def rv_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams, rv_win: int) -> list: + """Identical to S.skyhook_entries but using the RV-based htf_features.""" + feat = htf_features_rv(htf, p, rv_win) + m = S.merge_htf_to_ltf(ltf, feat) + c = m["close"].values.astype(float) + a = S.atr(m, p.ltf_atr_win) + comp_long = np.nan_to_num(m["comp_long"].values).astype(bool) + comp_short = np.nan_to_num(m["comp_short"].values).astype(bool) + days = pd.to_datetime(m["datetime"]).dt.floor("D").values + entries: list = [None] * len(m) + count_today: dict = {} + for i in range(len(m)): + if not np.isfinite(a[i]) or a[i] <= 0: + continue + day = days[i] + if count_today.get(day, 0) >= p.max_per_day: + continue + if comp_long[i]: + direction, mb = 1, p.uscitalong + elif comp_short[i]: + direction, mb = -1, p.uscitashort + else: + continue + if p.exit_mode == "atr": + sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i] + else: + sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i] + if direction == 1: + sl, tp = c[i] - sl_off, c[i] + tp_off + else: + sl, tp = c[i] + sl_off, c[i] - tp_off + entries[i] = {"dir": direction, "tp": float(tp), "sl": float(sl), "max_bars": int(mb)} + count_today[day] = count_today.get(day, 0) + 1 + return entries + + +# --------------------------------------------------------------------------- +# Backtest one asset with RV entries -> FULL + HOLDOUT metrics +# --------------------------------------------------------------------------- +def _split(eq, idx, mask): + e = eq[mask] + if len(e) < 5: + return dict(sharpe=0.0, ret=0.0, maxdd=0.0, n=int(len(e))) + r = np.diff(e) / e[:-1]; r = r[np.isfinite(r)] + ix = idx[mask] + dt = pd.Series(ix).diff().dt.total_seconds().median() or 86400 + bpy = 86400 * 365.25 / dt + sh = float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0 + pk = np.maximum.accumulate(e); dd = float(np.max((pk - e) / pk)) + return dict(sharpe=round(sh, 3), ret=round(float(e[-1] / e[0] - 1), 4), maxdd=round(dd, 4), n=int(len(e))) + + +def run_rv(asset: str, p: SkyhookParams, rv_win: int, fee=FEE) -> dict: + ltf, htf = sk.frames(asset) + ent = rv_entries(ltf, htf, p, rv_win) + m = backtest_signals(ltf, ent, fee_rt=fee, leverage=1.0, asset=asset, tf="230m") + eq = m.equity + idx = pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)) + hmask = np.asarray(idx >= HOLDOUT) + full = dict(sharpe=round(m.sharpe, 3), maxdd=round(m.max_dd, 4), ret=round(m.net_return, 4), + n_trades=int(m.n_trades), win_rate=round(m.win_rate, 1)) + hold = _split(eq, idx, hmask) + n_ent = int(sum(e is not None for e in ent)) + return dict(asset=asset, full=full, holdout=hold, n_entries=n_ent, _eq=eq, _idx=idx) + + +def causality_rv(p: SkyhookParams, rv_win: int, asset="BTC", tail=200) -> dict: + ltf, htf = sk.frames(asset) + full = rv_entries(ltf, htf, p, rv_win) + n = len(ltf); bad = 0; checked = 0 + for frac in (0.80, 0.92): + cut = int(n * frac) + cut_ts = int(ltf["timestamp"].iloc[cut - 1]) + htf_cut = htf[htf["timestamp"] <= cut_ts].reset_index(drop=True) + sub = rv_entries(ltf.iloc[:cut].reset_index(drop=True), htf_cut, p, rv_win) + for i in range(max(0, cut - tail), cut): + checked += 1 + a, b = full[i], sub[i] + if (a is None) != (b is None): + bad += 1 + elif a is not None and (a["dir"] != b["dir"] + or abs(a["sl"] - b["sl"]) > 1e-6 or abs(a["tp"] - b["tp"]) > 1e-6): + bad += 1 + return dict(ok=bool(bad == 0), mismatches=int(bad), checked=int(checked)) + + +def minrep(label, p, rv_win, fee=FEE): + rb = run_rv("BTC", p, rv_win, fee); re = run_rv("ETH", p, rv_win, fee) + mnf = min(rb["full"]["sharpe"], re["full"]["sharpe"]) + mnh = min(rb["holdout"]["sharpe"], re["holdout"]["sharpe"]) + mnt = min(rb["full"]["n_trades"], re["full"]["n_trades"]) + print(f" [{label} rv_win={rv_win}] minFull={mnf:+.2f} minHold={mnh:+.2f} minTr={mnt} " + f"BTC(F{rb['full']['sharpe']:+.2f}/H{rb['holdout']['sharpe']:+.2f}/DD{rb['full']['maxdd']*100:.0f}%/n{rb['full']['n_trades']}) " + f"ETH(F{re['full']['sharpe']:+.2f}/H{re['holdout']['sharpe']:+.2f}/DD{re['full']['maxdd']*100:.0f}%/n{re['full']['n_trades']})") + return mnf, mnh, mnt, rb, re + + +if __name__ == "__main__" and "--marginal" not in sys.argv: + # V1 geometry as the base (best known config). Sweep rv_win and the vola band. + base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + + print("=== SKH_R_RV: realized-vol BuzVola gate (V1 geometry) ===") + print("-- sweep rv_win (Chande lookback on annualized realized vol), V1 bands --") + grid = [] + for rv_win in (8, 13, 20, 34): + p = SkyhookParams(**base) + mnf, mnh, mnt, rb, re = minrep("rvband", p, rv_win) + grid.append((rv_win, mnf, mnh, mnt)) + + # pick best holdout among feasible (minFull>=0.5, minTr>=20) + feas = [g for g in grid if g[1] >= 0.5 and g[3] >= 20] + pool = feas if feas else grid + best = max(pool, key=lambda g: g[2]) + best_rv = best[0] + print(f"\n-- best rv_win by minHold (feasible): rv_win={best_rv} (minFull={best[1]:+.2f} minHold={best[2]:+.2f}) --") + + # Around best rv_win, try widening the vola band (RV distribution may differ from ATR's) + print("\n-- band variations at best rv_win --") + bandvars = [ + ("V1band", dict(vola_lo=35.0, vola_hi=95.0)), + ("wide", dict(vola_lo=25.0, vola_hi=98.0)), + ("midhi", dict(vola_lo=45.0, vola_hi=95.0)), + ("nogate", dict(vola_lo=0.0, vola_hi=100.0)), + ] + cand = [] + for nm, bd in bandvars: + pp = SkyhookParams(**{**base, **bd}) + mnf, mnh, mnt, rb, re = minrep(nm, pp, best_rv) + cand.append((nm, bd, mnf, mnh, mnt)) + + feas2 = [c for c in cand if c[2] >= 0.5 and c[4] >= 20] + pool2 = feas2 if feas2 else cand + win = max(pool2, key=lambda c: c[3]) + win_p = SkyhookParams(**{**base, **win[1]}) + print(f"\n=== WINNER: band={win[0]} rv_win={best_rv} minFull={win[2]:+.2f} minHold={win[3]:+.2f} ===") + + # Causality on winner (both assets) + cb = causality_rv(win_p, best_rv, "BTC") + ce = causality_rv(win_p, best_rv, "ETH") + causal_ok = cb["ok"] and ce["ok"] + print(f"causality: BTC={cb} ETH={ce} -> ok={causal_ok}") + + # Fee sweep on winner (min-asset full sharpe at each fee) + print("\n-- fee sweep (min-asset FULL sharpe) --") + fee_row = {} + for f in (0.0, 0.001, 0.002, 0.003): + rb = run_rv("BTC", win_p, best_rv, f); re = run_rv("ETH", win_p, best_rv, f) + fee_row[f"{f*100:.2f}%RT"] = round(min(rb["full"]["sharpe"], re["full"]["sharpe"]), 3) + print(" ", fee_row) + fee_survives = fee_row.get("0.30%RT", -9) > 0 + + # Marginal vs TP01 on winner. Build daily 50/50 series the same way skyhooklib does. + def daily_returns_rv(asset): + r = run_rv(asset, win_p, best_rv, FEE) + s = pd.Series(r["_eq"], index=r["_idx"]) + return s.resample("1D").last().ffill().pct_change().dropna() + import altlib as al + sb = daily_returns_rv("BTC"); se = daily_returns_rv("ETH") + J = pd.concat({"BTC": sb, "ETH": se}, axis=1, join="inner").fillna(0.0) + cand_daily = 0.5 * J["BTC"] + 0.5 * J["ETH"] + mg = al.marginal_vs_tp01(cand_daily) + print("\n-- marginal vs TP01 --") + print(f" corr_full={mg.get('corr_full')} verdict={mg.get('marginal_verdict')} " + f"w25_uplift_hold={mg.get('blends',{}).get('w25',{}).get('uplift_hold')} " + f"clean_year_uplift={mg.get('clean_year_uplift')} has_insample_edge={mg.get('has_insample_edge')} " + f"is_hedge={mg.get('is_hedge')} robust_oos={mg.get('robust_oos')}") + + # Final per-asset detail at winner + rb = run_rv("BTC", win_p, best_rv); re = run_rv("ETH", win_p, best_rv) + print("\n=== FINAL (winner) ===") + for a, r in (("BTC", rb), ("ETH", re)): + f, h = r["full"], r["holdout"] + print(f" {a}: FULL Sh={f['sharpe']:+.2f} ret={f['ret']*100:+.0f}% DD={f['maxdd']*100:.0f}% " + f"n={f['n_trades']} wr={f['win_rate']:.0f}% | HOLD Sh={h['sharpe']:+.2f} ret={h['ret']*100:+.0f}% " + f"DD={h['maxdd']*100:.0f}% n_entries={r['n_entries']}") + import json + summary = dict(label="R_RV", rv_win=best_rv, band=win[0], band_params=win[1], + min_full=round(win[2], 3), min_hold=round(win[3], 3), min_trades=int(win[4]), + btc_full=rb["full"]["sharpe"], eth_full=re["full"]["sharpe"], + btc_hold=rb["holdout"]["sharpe"], eth_hold=re["holdout"]["sharpe"], + avg_dd=round((rb["full"]["maxdd"] + re["full"]["maxdd"]) / 2, 4), + causality_ok=causal_ok, fee_survives=fee_survives, + corr_to_tp01=mg.get("corr_full"), + blend_w25_uplift_hold=mg.get("blends", {}).get("w25", {}).get("uplift_hold"), + marginal_verdict=mg.get("marginal_verdict")) + print("\nJSON " + json.dumps(summary, default=str)) + + +def _full_marginal(): + """Re-run winner and dump the COMPLETE marginal dict + per-year, for the final report.""" + import json, altlib as al + base = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=45.0, vola_hi=95.0, vol_lo=0.0) + p = SkyhookParams(**base); rv_win = 34 + def daily(asset): + r = run_rv(asset, p, rv_win, FEE) + s = pd.Series(r["_eq"], index=r["_idx"]) + return s.resample("1D").last().ffill().pct_change().dropna() + J = pd.concat({"BTC": daily("BTC"), "ETH": daily("ETH")}, axis=1, join="inner").fillna(0.0) + cd = 0.5 * J["BTC"] + 0.5 * J["ETH"] + mg = al.marginal_vs_tp01(cd) + print("FULL MARGINAL:", json.dumps({k: mg.get(k) for k in + ("corr_full","corr_hold","marginal_verdict","has_insample_edge","is_hedge", + "robust_oos","clean_year_uplift","jackknife_min_uplift","multicut_persistent", + "multicut_uplift","cand_full_sharpe","cand_hold_sharpe","alpha_ann","resid_sharpe_full", + "null_pctl_insample")}, default=str)) + print("BLENDS:", json.dumps(mg.get("blends"), default=str)) + # per-year via backtest yearly on each asset + for a in ("BTC","ETH"): + ltf, htf = sk.frames(a); ent = rv_entries(ltf, htf, p, rv_win) + m = backtest_signals(ltf, ent, fee_rt=FEE, leverage=1.0, asset=a, tf="230m") + yr = " ".join(f"{int(y)}:{v*100:+.0f}%" for y, v in m.yearly.items()) + print(f" {a} per-year: {yr}") + +if __name__ == "__main__" and "--marginal" in sys.argv: + _full_marginal() diff --git a/src/strategies/skyhook.py b/src/strategies/skyhook.py index 9137075..de2842c 100644 --- a/src/strategies/skyhook.py +++ b/src/strategies/skyhook.py @@ -135,13 +135,21 @@ class SkyhookParams: # uscite — time-based asimmetrico (barre LTF) uscitalong: int = 24 uscitashort: int = 18 - # uscite — hard stop/profit + # uscite — hard stop/profit (LONG, e SHORT se gli override sotto sono None) exit_mode: str = "atr" # 'atr' = multipli di ATR LTF ; 'pct' = percentuale fissa sl_atr: float = 2.0 tp_atr: float = 5.0 sl_pct: float = 0.03 tp_pct: float = 0.075 ltf_atr_win: int = 14 + # uscite — OVERRIDE asimmetrico SHORT (None = usa i valori simmetrici sopra). + # In crypto lo short si fa steamrollare da uno spike vola: stop short piu' stretti + # tagliano il draw-down standalone senza toccare il segnale (vedi SKH01-V2-DD, diario). + exit_mode_short: str | None = None + sl_atr_short: float | None = None + tp_atr_short: float | None = None + sl_pct_short: float | None = None + tp_pct_short: float | None = None # --------------------------------------------------------------------------- @@ -204,14 +212,21 @@ def skyhook_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams | Non continue if comp_long[i]: direction, mb = 1, p.uscitalong + mode, sl_a, tp_a, sl_p, tp_p = p.exit_mode, p.sl_atr, p.tp_atr, p.sl_pct, p.tp_pct elif comp_short[i]: direction, mb = -1, p.uscitashort + # SHORT: usa l'override asimmetrico dove presente, altrimenti i valori simmetrici. + mode = p.exit_mode_short if p.exit_mode_short is not None else p.exit_mode + sl_a = p.sl_atr_short if p.sl_atr_short is not None else p.sl_atr + tp_a = p.tp_atr_short if p.tp_atr_short is not None else p.tp_atr + sl_p = p.sl_pct_short if p.sl_pct_short is not None else p.sl_pct + tp_p = p.tp_pct_short if p.tp_pct_short is not None else p.tp_pct else: continue - if p.exit_mode == "atr": - sl_off, tp_off = p.sl_atr * a[i], p.tp_atr * a[i] + if mode == "atr": + sl_off, tp_off = sl_a * a[i], tp_a * a[i] else: - sl_off, tp_off = p.sl_pct * c[i], p.tp_pct * c[i] + sl_off, tp_off = sl_p * c[i], tp_p * c[i] if direction == 1: sl, tp = c[i] - sl_off, c[i] + tp_off else: @@ -221,6 +236,24 @@ def skyhook_entries(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams | Non return entries +# --------------------------------------------------------------------------- +# Config canoniche (vedi docs/diary/2026-06-23-skyhook.md) +# --------------------------------------------------------------------------- +# SKH01-V1: vincente del primo lever-scout/grid (regime gate + breakout lento + stop larghi). +SKH01_V1 = SkyhookParams(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0) + +# SKH01-V2-DD: vincente dell'onda DD-reduction (famiglia ASYM_LS). Stesso SEGNALE del winner +# intermedio (ptn_n=45, banda vola larga) ma EXIT a percentuale fissa ASIMMETRICA: short con SL +# piu' stretto (2% vs 4% long) -> taglia il draw-down standalone (maxDD BTC 21% / ETH 27% <30%) +# alzando hold-out e uplift di portafoglio. Verificato leak-free + 2 scettici avversariali. +SKH01_V2_DD = SkyhookParams( + ptn_n=45, vola_lo=35.0, vola_hi=95.0, vol_lo=0.0, + uscitalong=24, uscitashort=16, + exit_mode="pct", sl_pct=0.04, tp_pct=0.10, # LONG + exit_mode_short="pct", sl_pct_short=0.02, tp_pct_short=0.08, # SHORT (SL piu' stretto) +) + + def signal_counts(ltf: pd.DataFrame, htf: pd.DataFrame, p: SkyhookParams | None = None) -> dict: """Diagnostica: quante barre passano regime/pattern/composer (prima del cap giornaliero).""" p = p or SkyhookParams() diff --git a/tests/test_skyhook.py b/tests/test_skyhook.py index e3efc11..17ea8eb 100644 --- a/tests/test_skyhook.py +++ b/tests/test_skyhook.py @@ -15,7 +15,7 @@ sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "skyhook")) from src.data.downloader import load_data from src.strategies.skyhook import ( - HTF_MIN, LTF_MIN, SkyhookParams, build_frames, chande01, skyhook_entries) + HTF_MIN, LTF_MIN, SKH01_V2_DD, SkyhookParams, build_frames, chande01, skyhook_entries) # config V1 (vincente del lever-scout/grid; vedi diario 2026-06-23-skyhook) V1 = dict(ptn_n=55, sl_atr=2.5, tp_atr=6.0, vola_lo=35, vola_hi=95, vol_lo=0.0) @@ -89,3 +89,54 @@ def test_v1_robust_both_assets(): assert r["holdout"]["sharpe"] >= 0.2, f"{a} HOLD-OUT Sharpe basso: {r['holdout']['sharpe']}" assert r["full"]["n_trades"] >= 20, f"{a} troppo pochi trade: {r['full']['n_trades']}" assert sk.causality(p, "BTC")["ok"] and sk.causality(p, "ETH")["ok"] + + +# --------------------------------------------------------------------------- +# Exit asimmetrici SHORT (SKH01-V2-DD): l'override cambia SOLO gli short; i default +# (None) preservano esattamente il comportamento simmetrico precedente. +# --------------------------------------------------------------------------- +def test_short_override_backward_compatible(): + """Con gli override SHORT a None, gli ingressi sono identici alla versione simmetrica.""" + ltf, htf = build_frames(load_data("BTC", "5m")) + base = SkyhookParams(**V1) + # stessi parametri ma con campi override esplicitamente None (= default) + same = SkyhookParams(**V1, exit_mode_short=None, sl_pct_short=None, tp_pct_short=None) + e0, e1 = skyhook_entries(ltf, htf, base), skyhook_entries(ltf, htf, same) + assert e0 == e1, "i campi override a None NON devono cambiare nulla (backward-compat)" + + +def test_short_override_changes_only_shorts(): + """Un SL short piu' stretto (pct) modifica gli stop SHORT ma lascia intatti i LONG.""" + ltf, htf = build_frames(load_data("ETH", "5m")) + sym = SkyhookParams(ptn_n=45, vola_lo=35, vola_hi=95, vol_lo=0.0, + exit_mode="pct", sl_pct=0.04, tp_pct=0.10) + asym = SkyhookParams(ptn_n=45, vola_lo=35, vola_hi=95, vol_lo=0.0, + exit_mode="pct", sl_pct=0.04, tp_pct=0.10, + sl_pct_short=0.02, tp_pct_short=0.08) + es, ea = skyhook_entries(ltf, htf, sym), skyhook_entries(ltf, htf, asym) + longs_same = shorts_diff = 0 + for a, b in zip(es, ea): + if a is None or b is None: + assert (a is None) == (b is None) + continue + assert a["dir"] == b["dir"] + if a["dir"] == 1: # LONG invariati + assert abs(a["sl"] - b["sl"]) < 1e-6 and abs(a["tp"] - b["tp"]) < 1e-6 + longs_same += 1 + else: # SHORT con SL/TP diversi + assert abs(a["sl"] - b["sl"]) > 1e-6 + shorts_diff += 1 + assert longs_same > 0 and shorts_diff > 0 + + +def test_v2dd_robust_both_assets(): + """SKH01-V2-DD: PASS netto fee su BTCÐ, hold-out forte, e maxDD standalone <30%.""" + import skyhooklib as sk + p = SKH01_V2_DD + for a in ("BTC", "ETH"): + r = sk.run_asset(a, p, sk.FEE_RT) + assert r["full"]["sharpe"] >= 0.5, f"{a} FULL Sharpe basso: {r['full']['sharpe']}" + assert r["holdout"]["sharpe"] >= 0.5, f"{a} HOLD-OUT Sharpe basso: {r['holdout']['sharpe']}" + assert r["full"]["maxdd"] < 0.30, f"{a} maxDD non sotto 30%: {r['full']['maxdd']}" + assert r["full"]["n_trades"] >= 20, f"{a} troppo pochi trade: {r['full']['n_trades']}" + assert sk.causality(p, "BTC")["ok"] and sk.causality(p, "ETH")["ok"] From 8d1fe173f7a41bf3c754619354ecd73db8a9ea22 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 23 Jun 2026 16:22:15 +0000 Subject: [PATCH 6/7] feat(portfolio): wire SKH01-V2-DD sleeve @25% effective -> 4-sleeve book Add Skyhook (SKH01_V2_DD) as a portfolio sleeve. Effective weight 25%: the three existing sleeves scaled into the remaining 0.75 keeping their 55:25:20 ratio (TP01 41.25% / XS01 18.75% / VRP01 15% / SKH01 25%). _skyhook_returns(): 50/50 BTC+ETH daily series of the dual-TF regime+breakout engine (causal, net 0.10% RT), same convention as the marginal lens. Portfolio impact (run_portfolio.py), 3-sleeve -> 4-sleeve: FULL Sharpe 1.68 -> 2.13 (+0.45), FULL maxDD 14.3% -> 7.8% (halved) HOLD-OUT Sharpe 1.63 -> 2.30 (+0.67), HOLD-OUT maxDD ~3.5% (flat) Positive every year 2019-26 (annual DD <=7.8%) vs buy&hold 50/50 FULL Sh 0.93 / DD 76%. Skyhook is quasi-orthogonal (corr ~0.09 to TP01) so it lifts Sharpe AND cuts DD. Research portfolio (fixed weights, no real rebalancing cost at $600; Skyhook daily Sharpe is the step-marked lens convention) -> forward-monitor, not deploy. Tests: 25 pass (skyhook 8 + portfolio 7 + vrp 4 + trend 6). Diary + CLAUDE.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 18 ++++++++++--- docs/diary/2026-06-23-skyhook.md | 25 ++++++++++++++---- src/portfolio/sleeves.py | 45 +++++++++++++++++++++++++++++--- 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 712d1cc..e740152 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,23 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis monitor forward. NB il gate concentra XS nei regimi dispersi (2025-26 = hold-out alta-dispersione). Ricerca `scripts/portfolio/{xsec_research,xsec_blend,xsec_dispgate}.py`. Diari `2026-06-19-hyperliquid-xsec` / `-xsec-blend` / `-xsec-dispgate` / `-xsec-universe-expansion` / `-trend-multiasset`. -- **PORTAFOGLIO ATTIVO = TP01 (55%) + XS01 (25%) + VRP01 (20%)** (`src/portfolio/sleeves.active_sleeves`): +- **PORTAFOGLIO ATTIVO = TP01 (41.25%) + XS01 (18.75%) + VRP01 (15%) + SKH01 (25%)** (`src/portfolio/sleeves.active_sleeves`): TP01+XS01 combinato **FULL Sharpe 1.55, HOLD-OUT 1.55, DD 4.4%**. Aggiunto **VRP01** (options short-vol, sotto): TP01+VRP01 da solo fa FULL Sh 1.30→1.44 / HOLD 0.31→0.40 a peso 20% (3-way da - validare locale con dati HL). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio - diverse → outer-join con pesi rinormalizzati (TP01 da solo 2019-20, VRP dal 2021, blend pieno dal 2024). + validare locale con dati HL). **Aggiunto SKH01-V2-DD @25% effettivo (2026-06-23, sotto):** i tre + preesistenti scalati nel restante 0.75 (rapporto 55:25:20). Il portafoglio a **4 sleeve** fa + **FULL Sharpe 1.68→2.13, HOLD-OUT 1.63→2.30, DD full 14.3%→7.8%** (Skyhook è quasi-ortogonale, + corr ~0.09). Report `scripts/portfolio/run_portfolio.py`. Sleeve a date d'inizio + diverse → outer-join con pesi rinormalizzati (TP01/SKH01 dal 2019, VRP dal 2021, XS dal 2024). +- **SKH01-V2-DD "Skyhook" — DIVERSIFICATORE quasi-ortogonale (research)** — `src/strategies/skyhook.SKH01_V2_DD`, + sleeve `src/portfolio/sleeves._skyhook_returns`. Sistema dual-TF (segnale 690m / exec 230m) regime + (BuzVola/BuzVolume tipo-Chande) AND pattern (Donchian breakout), NON trend-follower, L/S. Vincitrice + di 2 onde multi-agente (la 2ª = DD-reduction): exit a **percentuale fissa ASIMMETRICA** (long sl4%/tp10%, + short sl2%/tp8% più stretto) → standalone **maxDD BTC 21% / ETH 27% (<30%)**, minFull +0.99, minHold + +1.26, causale (0/400), fee-surviving 0.40%RT. Marginal vs TP01 **ADDS** (corr 0.09, has_insample_edge, + robust_oos multicut 7/7, is_hedge=False); blend 0.75·TP01+0.25·SKH **hold-out 0.31→1.17**. Verificato + leak-free + 2 scettici. **CAVEAT:** equity daily-step (Sharpe lens), ETH DD margine sottile, book 230m + (costi ribilanciamento da verificare a deploy) → research win, forward-monitor. Diario `2026-06-23-skyhook.md`. - **VRP01 Options Short-Vol — DIVERSIFICATORE da FinanceOld/OptionsAgent** — `src/portfolio/sleeves._vrp_combo_returns`. Put credit spread settimanale (vendi put -0.28, compra put -0.10) gated su IV-rank. Idee portate da `../FinanceOld/OptionsAgent` (Bear Call Spread + gate d'ingresso). Migliora il lead VRP nudo diff --git a/docs/diary/2026-06-23-skyhook.md b/docs/diary/2026-06-23-skyhook.md index efbc68d..4c5242c 100644 --- a/docs/diary/2026-06-23-skyhook.md +++ b/docs/diary/2026-06-23-skyhook.md @@ -119,8 +119,23 @@ causalità/fee/plateau/overfit). Esito: **il winner intermedio cade.** Nuovo cam **Promozione (questa sessione):** `SKH01_V2_DD` canonico nel motore + override exit-short asimmetrici (backward-compatible, V1/winner invariati) + 3 test nuovi (8/8 pass). -**Caveat onesti / NON ancora deployato:** ETH DD 27.4% ha margine sottile vs 30% (BTC 21.4% comodo) → -monitorare ETH. Lo sleeve di portafoglio NON è ancora cablato in `active_sleeves`: prima del deploy va -replicato il blend a peso 0.25 nel portafoglio attivo coi dati live e ri-verificata la causalità sul -**codice di esecuzione reale** (le verifiche qui sono sull'harness di ricerca). Per ora: research win, -candidato sleeve forward-monitor. + +**Sleeve cablato @0.25 effettivo** (`src/portfolio/sleeves.skyhook_sleeve` → `active_sleeves`): i tre +sleeve preesistenti scalati nel restante 0.75 mantenendo il rapporto 55:25:20 → **TP01 41.25% / XS01 +18.75% / VRP01 15% / SKH01 25%**. Report del portafoglio (4 sleeve, `run_portfolio.py`): + +| | FULL Sharpe | FULL DD | HOLD-OUT Sharpe | HOLD-OUT DD | +|---|---|---|---|---| +| 3 sleeve (TP01+XS01+VRP01) | 1.68 | 14.3% | 1.63 | 3.4% | +| **+ SKH01 @25%** | **2.13** | **7.8%** | **2.30** | 3.5% | +| Δ | **+0.45** | **−6.5pt** | **+0.67** | ~0 | + +→ aggiungere Skyhook **alza lo Sharpe full +0.45 e DIMEZZA il DD full (14.3→7.8%)**, e alza l'hold-out ++0.67 a DD invariato. Portafoglio combinato: FULL Sh 2.13 / ret +365% / DD 7.8%, HOLD Sh 2.30 / DD 3.5%, +positivo ogni anno (2019-26, DD annuo ≤7.8%) vs buy&hold 50/50 FULL Sh 0.93 / DD 76%. + +**Caveat onesti / NON deploy:** è un portafoglio di **ricerca** (peso fisso, no costi di ribilanciamento +reale a $600; lo Sharpe daily-step di Skyhook è la convenzione del lens). ETH DD standalone 27.4% ha +margine sottile vs 30%. Prima di un eventuale deploy: ri-verificare la causalità sul **codice di +esecuzione reale** (qui è l'harness di ricerca) e i costi del book a 230m (ribilanciamento più frequente +del resto). XS01/VRP01 restano STAT-MODE/lead. Per ora: research win + sleeve cablato, forward-monitor. diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py index fb4537a..db018ff 100644 --- a/src/portfolio/sleeves.py +++ b/src/portfolio/sleeves.py @@ -207,12 +207,49 @@ def vrp_sleeve(weight: float = 0.20) -> Sleeve: return Sleeve("VRP01_shortvol", weight, _vrp_combo_returns) +# ----------------------------- SKH01-V2-DD: Skyhook dual-TF regime+breakout (BTC/ETH) ----------------------------- +# Sistema dual-timeframe (segnale 690m, exec 230m): entra solo quando coincidono REGIME +# (BuzVola/BuzVolume tipo-Chande) E PATTERN (Donchian breakout). NON e' un trend-follower. +# Vincitrice dell'onda DD-reduction (famiglia ASYM_LS): exit a percentuale fissa ASIMMETRICA +# (long sl4%/tp10%, short sl2%/tp8% piu' stretto) -> taglia il DD standalone (BTC 21% / ETH 27%) +# alzando hold-out (minHold +1.26) e valore di portafoglio. Quasi-ortogonale a TP01 (corr ~0.09): +# blend 0.75*TP01+0.25*SKH -> hold-out Sharpe 0.31->1.17 (+0.87), DD full 14%->9%. Marginal ADDS, +# has_insample_edge, robust_oos (multicut 7/7 anni), is_hedge=False. Verificato leak-free (causalita' +# 0/400) + 2 scettici avversariali. Diario 2026-06-23-skyhook.md. +# CAVEAT ONESTI: equity marcata a fine-trade (daily lumpy); ETH DD 27% ha margine sottile vs 30%; +# il book opera a 230m -> ribilanciamento piu' frequente del resto (verificare costi reali a deploy). +from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries +from src.backtest.harness import backtest_signals + + +def _skyhook_returns() -> pd.Series: + """SKH01-V2-DD: book 50/50 BTC+ETH del sistema regime+breakout dual-TF, riportato su griglia + GIORNALIERA. Causale (decide a close[i], exit intrabar TP/SL/max_bars, non-overlap), netto 0.10% RT.""" + series = {} + for a in ASSETS: + ltf, htf = build_frames(load_data(a, "5m")) + ent = skyhook_entries(ltf, htf, SKH01_V2_DD) + m = backtest_signals(ltf, ent, fee_rt=0.001, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + series[a] = s.resample("1D").last().ffill().pct_change().dropna() + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) + + +def skyhook_sleeve(weight: float = 0.25) -> Sleeve: + return Sleeve("SKH01_skyhook", weight, _skyhook_returns) + + # ----------------------------- REGISTRY ----------------------------- def active_sleeves() -> list[Sleeve]: """Sleeve ATTIVI nel portafoglio (pesi rinormalizzati; sleeve a date diverse si attivano - quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet.""" + quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet. + + SKH01 cablato @0.25 effettivo: i tre sleeve preesistenti scalati nel restante 0.75 mantenendo + il loro rapporto 55:25:20 (-> 41.25/18.75/15), cosi' Skyhook pesa esattamente 25% del book.""" return [ - tp01_sleeve(weight=0.55), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno) - xsec_sleeve(weight=0.25), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode) - vrp_sleeve(weight=0.20), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato) + tp01_sleeve(weight=0.4125), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno) + xsec_sleeve(weight=0.1875), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode) + vrp_sleeve(weight=0.15), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato) + skyhook_sleeve(weight=0.25), # dual-TF regime+breakout BTC/ETH, dal 2019 (quasi-ortogonale, exit %-asimmetrici, research) ] From 7eb0f67956bc1d35de950dea806c801bb36615c9 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 23 Jun 2026 16:41:52 +0000 Subject: [PATCH 7/7] feat(dashboard): show SKH01 sleeve in 4-sleeve portfolio view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit active_sleeves() already feeds the per-sleeve table & combined metrics, so SKH01 appears automatically. Manual touch-ups: title/docstring -> +SKH01; position label is now sleeve-aware (the None fallback used to mislabel every pos-fn-less sleeve as XS01's "book 19 gambe" — now XS01/SKH01/VRP01 get correct labels); footer note adds SKH01 (quasi-orthogonal @25%, FULL Sharpe 1.68->2.13, DD 14->8%, research/forward-monitor). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/live/dashboard.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/live/dashboard.py b/src/live/dashboard.py index 1803147..a913e48 100644 --- a/src/live/dashboard.py +++ b/src/live/dashboard.py @@ -1,4 +1,4 @@ -"""DASHBOARD web del portafoglio attivo (TP01 + XS01) — monitoraggio PAPER, stdlib only. +"""DASHBOARD web del portafoglio attivo (TP01 + XS01 + VRP01 + SKH01) — monitoraggio PAPER, stdlib only. Mostra: metriche (FULL/HOLD Sharpe, DD, CAGR), per-sleeve, posizioni correnti, equity (backtest + paper forward da scripts/live/paper_portfolio.py), ultima data dato. Nessuna auth -> solo rete @@ -87,7 +87,19 @@ def html(): yrs = "".join(f"{y}: {v['ret']*100:+.0f}%" for y, v in sorted(d["yearly"].items())) pos = "" for sl, p in d["positions"].items(): - pos += f"{sl}{'flat (in cash)' if p == {'BTC': 0.0, 'ETH': 0.0} else (p if p is not None else 'stat-mode (book 19 gambe)')}" + if p == {'BTC': 0.0, 'ETH': 0.0}: + ptxt = 'flat (in cash)' + elif p is not None: + ptxt = str(p) + elif 'XS01' in sl: + ptxt = 'stat-mode (book 19 gambe)' + elif 'SKH' in sl: + ptxt = 'forward-monitor (segnale dual-TF, no pos-fn)' + elif 'VRP' in sl: + ptxt = 'stat-mode (book opzioni settimanale)' + else: + ptxt = 'n/d' + pos += f"{sl}{ptxt}" pp = d["paper"] if pp: days = (pd.Timestamp(pp["last"]) - pd.Timestamp(pp["start"])).days @@ -186,7 +198,7 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b .warn{{color:#f1c40f;font-size:12px}} .section{{font-size:15px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin:34px 0 14px;padding:10px 14px;border-radius:9px;background:#12181f;border-left:5px solid #2ecc71;color:#d7dee6}} .section.live{{border-left-color:#e74c3c;background:#1c1316;color:#f0c4c4}} -

PythagorasGoal — Portafoglio attivo (TP01 + XS01 + VRP01)

+

PythagorasGoal — Portafoglio attivo (TP01 + XS01 + VRP01 + SKH01)

monitor · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE non attiva (solo micro-test)
PAPER — simulato (backtest + forward virtuale)
@@ -216,7 +228,7 @@ th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;b
Shadow TP01 (cosa farebbe ORA sul conto reale, nessun ordine inviato):
{shadow_html}

Trades REALI eseguiti su Deribit

{live_trows}
data/ora UTCstrum.diramountprezzofee USDC
-

⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). TP01 e' l'unico deployable pieno: lo "Shadow live" mostra cosa farebbe sul mainnet, ma NON invia ordini.

+

⚠️ Paper/monitor. XS01 e' STAT-MODE (book a 19 gambe market-neutral, non eseguibile a €2k, storia ~2.5 anni). VRP01 = lead short-vol MODELLATO (non deploy pieno). SKH01 (Skyhook dual-TF regime+breakout, BTC/ETH) = diversificatore quasi-ortogonale (corr ~0.09) aggiunto @25%: alza il FULL Sharpe del portafoglio 1.68→2.13 e dimezza il DD (14→8%) — RESEARCH/forward-monitor (book a 230m, causalita' verificata su harness ma costi reali e codice d'esecuzione da validare prima del deploy). TP01 e' l'unico deployable pieno: lo "Shadow live" mostra cosa farebbe sul mainnet, ma NON invia ordini.

"""