diff --git a/scripts/portfolio/run_deribit_book.py b/scripts/portfolio/run_deribit_book.py new file mode 100644 index 0000000..40f64a8 --- /dev/null +++ b/scripts/portfolio/run_deribit_book.py @@ -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() diff --git a/src/portfolio/portfolio.py b/src/portfolio/portfolio.py index 14ce918..d2e3448 100644 --- a/src/portfolio/portfolio.py +++ b/src/portfolio/portfolio.py @@ -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: diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py index db018ff..fd44186 100644 --- a/src/portfolio/sleeves.py +++ b/src/portfolio/sleeves.py @@ -253,3 +253,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), + ] diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index bbe66e2..39a9469 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -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])