64d98a070d
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) <noreply@anthropic.com>
218 lines
9.4 KiB
Python
218 lines
9.4 KiB
Python
"""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()))
|