Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 384b9cb0af | |||
| 160ad300be | |||
| 50e2adf837 |
@@ -0,0 +1,86 @@
|
||||
"""REPORT del BOOK DERIBIT-ONLY realmente eseguibile = TP01 + SKH01 (75/25).
|
||||
|
||||
Le due gambe direzionali BTC/ETH sullo STESSO venue (Deribit), entrambe dal 2019. Esclude XS01
|
||||
(Hyperliquid, stat-mode) e VRP01 (opzioni modellate). Mostra:
|
||||
1. metriche oneste continuo (rebalance-continuo) vs RIBILANCIAMENTO PERIODICO realistico
|
||||
(settimanale/mensile) con costo turnover Deribit-taker;
|
||||
2. per-anno, accumulo da €2k (e nota €600 reale + min-order $5);
|
||||
3. posizioni correnti per gamba.
|
||||
|
||||
uv run python scripts/portfolio/run_deribit_book.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
|
||||
from src.portfolio.portfolio import StrategyPortfolio, metrics, yearly, rebalance_sim, HOLDOUT
|
||||
|
||||
CAP = 2000.0
|
||||
REAL = 600.0 # capitale reale (vedi CLAUDE.md), min-order Deribit $5
|
||||
COST_RATE = 0.0005 # Deribit taker per-lato (~0.10% RT sul turnover netto)
|
||||
|
||||
|
||||
def line(tag, daily, extra=""):
|
||||
m = metrics(daily); h = metrics(daily[daily.index >= HOLDOUT])
|
||||
eqf = CAP * float(np.prod(1.0 + daily.values))
|
||||
print(f" {tag:<26} FULL Sh {m['sharpe']:.2f} ret {m['ret']*100:+.0f}% DD {m['maxdd']*100:.1f}% "
|
||||
f"| HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% | €2k→€{eqf:,.0f} {extra}")
|
||||
return m, h
|
||||
|
||||
|
||||
def main():
|
||||
from src.portfolio.sleeves import deribit_book_sleeves
|
||||
sleeves = deribit_book_sleeves()
|
||||
pf = StrategyPortfolio(sleeves, capital=CAP)
|
||||
w = pf.weights()
|
||||
cols = {s.name: s.daily() for s in sleeves}
|
||||
|
||||
print("=" * 100)
|
||||
print(f" BOOK DERIBIT-ONLY (eseguibile) — {' + '.join(f'{k} {v*100:.0f}%' for k, v in w.items())} "
|
||||
f"| capitale €{CAP:,.0f} (reale ≈ ${REAL:,.0f}) | hold-out {HOLDOUT.date()}+")
|
||||
print("=" * 100)
|
||||
|
||||
# standalone per-gamba
|
||||
print("\n PER-GAMBA (standalone):")
|
||||
for s in sleeves:
|
||||
d = s.daily(); m = metrics(d); h = metrics(d[d.index >= HOLDOUT])
|
||||
print(f" {s.name:<16} [{w[s.name]*100:>3.0f}%] FULL Sh {m['sharpe']:.2f} DD {m['maxdd']*100:.0f}% "
|
||||
f"| HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.0f}%")
|
||||
|
||||
print("\n COMBINATO — rebalance-CONTINUO (idealizzato, no costi) vs PERIODICO (reale, costo turnover):")
|
||||
cont = pf.combined_daily()
|
||||
line("continuo (no costo)", cont)
|
||||
sims = {}
|
||||
for tag, period in (("settimanale (7g)", 7), ("bisettimanale (14g)", 14), ("mensile (30g)", 30)):
|
||||
sim = rebalance_sim(cols, w, period_days=period, cost_rate=COST_RATE)
|
||||
sims[tag] = sim
|
||||
line(f"rebal {tag}", sim["daily"], extra=f"| turnover {sim['turnover_per_year']:.1f}×/anno, {sim['n_rebalances']} ribilanci")
|
||||
|
||||
# raccomandato = mensile
|
||||
rec = sims["mensile (30g)"]["daily"]
|
||||
print("\n PER ANNO (rebal mensile, netto costo):")
|
||||
for y, d in yearly(rec).items():
|
||||
print(f" {y}: ret {d['ret']*100:>+7.1f}% DD {d['dd']*100:>5.1f}%")
|
||||
|
||||
print("\n ACCUMULO (rebal mensile):")
|
||||
for cap, lbl in ((CAP, "€2k nominale"), (REAL, "$600 reale")):
|
||||
eq = cap * np.cumprod(1.0 + rec.values)
|
||||
yrs = len(rec) / 365.25
|
||||
print(f" {lbl:<14}: {cap:,.0f} → {eq[-1]:,.0f} (×{eq[-1]/cap:.1f}, ~{(eq[-1]-cap)/(yrs*365.25):+,.2f}/g)")
|
||||
|
||||
print("\n POSIZIONI CORRENTI (ultima barra chiusa):")
|
||||
for name, pos in pf.current_positions().items():
|
||||
print(f" {name}: {pos if pos is not None else 'segnale dual-TF (no pos-fn) — vedi engine'}")
|
||||
|
||||
print("\n NOTE ONESTE:")
|
||||
print(" · TP01 = unico armato live su Deribit (flat=risk-off). SKH01 = 2a gamba candidata (perp BTC/ETH).")
|
||||
print(" · SKH01 equity daily-step (Sharpe lens). A $600 il min-order è $5: un ribilancio mensile")
|
||||
print(" muove abbastanza nozionale da eseguirsi; il giornaliero NO (Δ sub-$5 = finzione) → usa ≥ settimanale.")
|
||||
print(" · Prima del deploy 2a gamba: validare causalità sul CODICE D'ESECUZIONE reale e costi del book a 230m.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -68,6 +68,44 @@ def yearly(daily: pd.Series) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def rebalance_sim(daily_cols: dict[str, pd.Series], weights: dict,
|
||||
period_days: int, cost_rate: float = 0.0005) -> dict:
|
||||
"""Ribilanciamento PERIODICO REALISTICO (vs il rebalance-continuo implicito di combined_daily).
|
||||
|
||||
Tra una data di ribilanciamento e l'altra ogni sleeve DERIVA col suo rendimento (i pesi si
|
||||
scostano dal target). Ogni `period_days` si riporta al target pagando il turnover:
|
||||
cost = cost_rate * sum_i |valore_i - target_i| (cost_rate = fee per-lato, Deribit taker ~0.0005)
|
||||
Modella l'attrito reale che il rebalance-continuo (combined_daily) ignora. period_days=1 con
|
||||
cost_rate=0 ricade sul rebalance-continuo. Ritorna serie netta + turnover annuo + n ribilanci."""
|
||||
J = pd.concat(daily_cols, axis=1, join="inner").sort_index().fillna(0.0)
|
||||
cols = list(J.columns)
|
||||
w = np.array([weights[c] for c in cols], float); w = w / w.sum()
|
||||
R = J.values
|
||||
n = len(J)
|
||||
E = 1.0
|
||||
v = w * E
|
||||
out = np.zeros(n)
|
||||
n_rebal = 0
|
||||
turn_tot = 0.0
|
||||
for t in range(n):
|
||||
Eprev = E
|
||||
v = v * (1.0 + R[t])
|
||||
E = float(v.sum())
|
||||
if (t + 1) % period_days == 0: # giorno di ribilanciamento
|
||||
target = w * E
|
||||
turn = float(np.abs(v - target).sum())
|
||||
cost = cost_rate * turn
|
||||
E -= cost
|
||||
v = w * E
|
||||
n_rebal += 1
|
||||
turn_tot += turn / max(Eprev, 1e-12)
|
||||
out[t] = E / Eprev - 1.0 if Eprev > 0 else 0.0
|
||||
years = n / DAYS_PER_YEAR
|
||||
return dict(daily=pd.Series(out, index=J.index),
|
||||
turnover_per_year=round(turn_tot / years, 2) if years > 0 else 0.0,
|
||||
n_rebalances=n_rebal, period_days=period_days, cost_rate=cost_rate)
|
||||
|
||||
|
||||
class StrategyPortfolio:
|
||||
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
|
||||
if not sleeves:
|
||||
|
||||
@@ -236,8 +236,41 @@ def _skyhook_returns() -> pd.Series:
|
||||
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
||||
|
||||
|
||||
def _skyhook_positions() -> dict:
|
||||
"""Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA
|
||||
-> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di
|
||||
entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse."""
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
ltf, htf = build_frames(load_data(a, "5m"))
|
||||
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||
H = ltf["high"].values; L = ltf["low"].values; Cc = ltf["close"].values
|
||||
n = len(ltf); i = 0; open_pos = "flat"
|
||||
while i < n:
|
||||
e = ent[i]
|
||||
if e is None:
|
||||
i += 1; continue
|
||||
d, sl, tp, mb = e["dir"], e["sl"], e["tp"], e["max_bars"]
|
||||
exit_idx = None
|
||||
for s in range(1, mb + 1):
|
||||
j = i + s
|
||||
if j >= n: # non ancora uscito: posizione APERTA ora
|
||||
break
|
||||
hit = (L[j] <= sl or H[j] >= tp) if d == 1 else (H[j] >= sl or L[j] <= tp)
|
||||
if hit or s == mb:
|
||||
exit_idx = j; break
|
||||
if exit_idx is None:
|
||||
open_pos = dict(dir="LONG" if d == 1 else "SHORT", entry=round(float(Cc[i]), 2),
|
||||
sl=round(float(sl), 2), tp=round(float(tp), 2),
|
||||
bars_in=int((n - 1) - i), max_bars=int(mb))
|
||||
break
|
||||
i = exit_idx + 1
|
||||
out[a] = open_pos
|
||||
return out
|
||||
|
||||
|
||||
def skyhook_sleeve(weight: float = 0.25) -> Sleeve:
|
||||
return Sleeve("SKH01_skyhook", weight, _skyhook_returns)
|
||||
return Sleeve("SKH01_skyhook", weight, _skyhook_returns, pos_fn=_skyhook_positions)
|
||||
|
||||
|
||||
# ----------------------------- REGISTRY -----------------------------
|
||||
@@ -253,3 +286,16 @@ def active_sleeves() -> list[Sleeve]:
|
||||
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)
|
||||
]
|
||||
|
||||
|
||||
def deribit_book_sleeves() -> list[Sleeve]:
|
||||
"""BOOK DERIBIT-ONLY realmente eseguibile (TP01 + SKH01, 75/25): le DUE gambe direzionali
|
||||
BTC/ETH sullo stesso venue (Deribit), entrambe dal 2019. Esclude XS01 (Hyperliquid, stat-mode)
|
||||
e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step).
|
||||
Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento
|
||||
periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py).
|
||||
TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione)."""
|
||||
return [
|
||||
tp01_sleeve(weight=0.75),
|
||||
skyhook_sleeve(weight=0.25),
|
||||
]
|
||||
|
||||
+32
-1
@@ -7,7 +7,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics, rebalance_sim
|
||||
|
||||
|
||||
def _const_sleeve(name, weight, val, n=400):
|
||||
@@ -15,6 +15,37 @@ def _const_sleeve(name, weight, val, n=400):
|
||||
return Sleeve(name, weight, lambda: pd.Series(val, index=idx))
|
||||
|
||||
|
||||
def _ret_series(vals):
|
||||
idx = pd.date_range("2020-01-01", periods=len(vals), freq="1D", tz="UTC")
|
||||
return pd.Series(vals, index=idx)
|
||||
|
||||
|
||||
def test_rebalance_sim_no_cost_period1_matches_continuous():
|
||||
"""period=1 + cost=0 deve coincidere col rebalance-continuo (weighted-return giornaliero)."""
|
||||
rng = np.random.default_rng(0)
|
||||
A = _ret_series(rng.normal(0.001, 0.02, 300))
|
||||
B = _ret_series(rng.normal(0.000, 0.03, 300))
|
||||
w = {"A": 0.6, "B": 0.4}
|
||||
sim = rebalance_sim({"A": A, "B": B}, w, period_days=1, cost_rate=0.0)
|
||||
cont = 0.6 * A + 0.4 * B
|
||||
assert np.allclose(sim["daily"].values, cont.values, atol=1e-12)
|
||||
assert sim["n_rebalances"] == 300
|
||||
|
||||
|
||||
def test_rebalance_sim_cost_reduces_return_and_counts():
|
||||
"""Il costo del turnover abbassa il rendimento; ribilanci meno frequenti = meno costo."""
|
||||
rng = np.random.default_rng(1)
|
||||
A = _ret_series(rng.normal(0.001, 0.02, 360))
|
||||
B = _ret_series(rng.normal(0.001, 0.04, 360))
|
||||
w = {"A": 0.5, "B": 0.5}
|
||||
free = rebalance_sim({"A": A, "B": B}, w, period_days=7, cost_rate=0.0)["daily"]
|
||||
weekly = rebalance_sim({"A": A, "B": B}, w, period_days=7, cost_rate=0.001)
|
||||
monthly = rebalance_sim({"A": A, "B": B}, w, period_days=30, cost_rate=0.001)
|
||||
assert weekly["daily"].sum() < free.sum() # il costo morde
|
||||
assert monthly["n_rebalances"] < weekly["n_rebalances"] # mensile ribilancia meno
|
||||
assert weekly["turnover_per_year"] > 0
|
||||
|
||||
|
||||
def test_single_sleeve_equals_itself():
|
||||
s = _const_sleeve("A", 1.0, 0.001)
|
||||
pf = StrategyPortfolio([s])
|
||||
|
||||
@@ -129,6 +129,16 @@ def test_short_override_changes_only_shorts():
|
||||
assert longs_same > 0 and shorts_diff > 0
|
||||
|
||||
|
||||
def test_skyhook_pos_fn_structure():
|
||||
"""pos_fn introspettiva: dict BTC/ETH, ciascuno 'flat' o dict con dir/entry/sl/tp coerenti."""
|
||||
from src.portfolio.sleeves import _skyhook_positions
|
||||
pos = _skyhook_positions()
|
||||
assert set(pos.keys()) == {"BTC", "ETH"}
|
||||
for a, p in pos.items():
|
||||
assert p == "flat" or (isinstance(p, dict) and p["dir"] in ("LONG", "SHORT")
|
||||
and p["sl"] > 0 and p["tp"] > 0 and 0 <= p["bars_in"] <= p["max_bars"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user