diff --git a/CLAUDE.md b/CLAUDE.md index 68cb8fa..de1276c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,13 +66,17 @@ netto fee, out-of-sample, robusto su griglia, e su dati certificati + liquidi + src/data/downloader.py → load_data(asset, tf): legge i parquet certificati da data/raw/ src/strategies/base.py → Strategy (ABC), Signal, BacktestResult, YearlyStats src/strategies/indicators.py → indicatori condivisi (ema, atr, keltner, ...) -src/strategies/trend_portfolio.py → TP01: strategia VINCENTE (PORT LF4h), causale, deployabile +src/strategies/trend_portfolio.py → TP01: strategia DIFENSIVA robusta (PORT LF1d, >=12h), causale +src/portfolio/ → PORTAFOGLIO DI STRATEGIE estensibile (Sleeve + StrategyPortfolio) + portfolio.py → combina N sleeve per peso su griglia giornaliera; metriche FULL/hold-out/anno + sleeves.py → REGISTRY sleeve attivi (ora solo TP01). Aggiungere = una riga qui src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py) src/backtest/engine.py → engine di backtesting riusabile src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS) src/version.py → APP_VERSION (legge il file VERSION) scripts/research/ → ricerca post-reset: track{A-E}_*.py (trend/ML/MR/portfolio/xsec) -scripts/live/paper_trend.py → paper trader forward-only di TP01 (no esecuzione reale) +scripts/portfolio/run_portfolio.py → report del portafoglio attivo (pesi, FULL/hold-out/anno, vs B&H) +scripts/live/paper_trend.py → paper trader forward-only di TP01 (1d) (no esecuzione reale) scripts/analysis/ → SOLO i tool dati certificati: rebuild_history.py → (ri)costruisce lo storico da Deribit mainnet (base 5m + resample) certify_feed.py → certifica il feed (integrità, coerenza resample, spike, cross-venue) @@ -94,7 +98,8 @@ uv run python scripts/analysis/certify_feed.py # certifica i feed uv run python scripts/analysis/certify_feed.py --local # solo check locali (veloce) uv run python scripts/research/trackD_trendport.py # backtest strategia vincente (full report) uv run python scripts/research/trackD_timing.py # vincitrice su 15m/1h/4h/1d + PnL/DD/trade per anno -uv run python scripts/live/paper_trend.py # avanza il paper trader TP01 (forward-only) +uv run python scripts/portfolio/run_portfolio.py # report del PORTAFOGLIO attivo (sleeve + metriche) +uv run python scripts/live/paper_trend.py # avanza il paper trader TP01 (forward-only, 1d) uv run pytest # test ``` diff --git a/docs/diary/2026-06-19-strategy-portfolio.md b/docs/diary/2026-06-19-strategy-portfolio.md new file mode 100644 index 0000000..6803955 --- /dev/null +++ b/docs/diary/2026-06-19-strategy-portfolio.md @@ -0,0 +1,31 @@ +# 2026-06-19 — Portafoglio di strategie estensibile (TP01 primo sleeve) + +Creato un contenitore di portafoglio (`src/portfolio/`) con TP01 come unico sleeve attivo per ora, +progettato per aggiungerne altri (ognuno validato col gauntlet onesto). + +## Design +- **Sleeve** = una strategia validata che produce una serie di rendimenti netti per-barra + (datetime-indexed, CAUSALE, netto fee). Opzionale `pos_fn` per le posizioni correnti (live). +- **StrategyPortfolio**: porta ogni sleeve su griglia GIORNALIERA comune (compounding intra-giorno + → mixa TF diversi in modo coerente), combina per PESO rinormalizzato sui giorni comuni + (= equal-capital-by-weight ribilanciato di continuo). Metriche FULL + HOLD-OUT 2025-26 (bloccato) + + per-anno + standalone per-sleeve, vs benchmark buy&hold 50/50. +- **Estensibilità**: aggiungere uno sleeve = una riga in `src/portfolio/sleeves.active_sleeves` + (dopo validazione: research_lab + hold-out + cross-asset + causality guard). Niente sleeve non validati. + +## Stato attuale (1 sleeve = TP01, peso 100%) +`scripts/portfolio/run_portfolio.py`: +- **FULL** Sharpe 1.30 / ret +201% / DD 14.3% / ~€1.52/g su 2k (n=2655 giorni 2019-2026) +- **HOLD-OUT 2025-26** Sharpe 0.31 / +3.5% / DD 7.5% (buy&hold 50/50: Sharpe −0.32 / −39% / DD 59%) +- Per-anno positivo quasi ovunque (2022 −2.1%, 2026-YTD −0.7%) +- Posizione corrente: **flat** (TP01 in cash nel regime attuale = difensivo) + +## File +- `src/portfolio/{__init__,portfolio,sleeves}.py`, `scripts/portfolio/run_portfolio.py`, + `tests/test_portfolio.py` (6 test, passano). CLAUDE.md aggiornato. + +## Prossimo +Il portafoglio è pronto per ospitare nuovi sleeve. Candidati naturali (da validare prima): +un secondo edge scorrelato a TP01 (TP01 è trend long-flat → serve qualcosa di diverso, es. una +strategia che lavori quando TP01 è flat). Finché non c'è un secondo edge che regge il gauntlet, +il portafoglio = TP01 difensivo. Quando arriverà, basta una riga in sleeves.py. diff --git a/scripts/portfolio/run_portfolio.py b/scripts/portfolio/run_portfolio.py new file mode 100644 index 0000000..eb0d5d6 --- /dev/null +++ b/scripts/portfolio/run_portfolio.py @@ -0,0 +1,75 @@ +"""REPORT del portafoglio di strategie attivo (estensibile). + +Costruisce il portafoglio dagli sleeve attivi (src/portfolio/sleeves.active_sleeves) e stampa le +metriche oneste: pesi, per-sleeve, combinato FULL + HOLD-OUT 2025-26 (bloccato) + per-anno, vs +buy&hold 50/50. Per ora c'e' solo TP01; aggiungere sleeve = una riga in sleeves.py. + + uv run python scripts/portfolio/run_portfolio.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 +import pandas as pd + +from src.data.downloader import load_data +from src.strategies.trend_portfolio import resample_1d, simple_returns +from src.portfolio.portfolio import StrategyPortfolio, to_daily, metrics, HOLDOUT +from src.portfolio.sleeves import active_sleeves + +CAPITAL = 2000.0 + + +def buy_hold_daily() -> pd.Series: + s = {} + for a in ("BTC", "ETH"): + df = resample_1d(load_data(a, "1h")) + s[a] = pd.Series(simple_returns(df["close"].values.astype(float)), index=pd.to_datetime(df["datetime"])) + J = pd.concat(s, axis=1, join="inner").fillna(0.0) + return to_daily(pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)) + + +def fmt(m, cap=CAPITAL): + yrs = m["n"] / 365.25 + eur_day = (cap * m["ret"]) / (yrs * 365.25) if yrs > 0 else 0.0 + return (f"Sh {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% CAGR {m['cagr']*100:>+6.1f}% | " + f"DD {m['maxdd']*100:>5.1f}% | ~€/g(2k) {eur_day:>+5.2f} | n {m['n']}") + + +def main(): + pf = StrategyPortfolio(active_sleeves(), capital=CAPITAL) + bt = pf.backtest() + print("=" * 96) + print(f" PORTAFOGLIO DI STRATEGIE — {len(pf.sleeves)} sleeve | capitale {CAPITAL:,.0f} | hold-out {HOLDOUT.date()}+ bloccato") + print("=" * 96) + + print("\n PESI:", " ".join(f"{k} {v*100:.0f}%" for k, v in bt["weights"].items())) + + print("\n PER-SLEEVE (standalone):") + for name, d in bt["per_sleeve"].items(): + print(f" {name:<16s} [{d['weight']*100:>3.0f}%] FULL {fmt(d['full'])}") + print(f" {'':<16s} HOLD {fmt(d['holdout'])}") + + print("\n PORTAFOGLIO COMBINATO:") + print(f" FULL {fmt(bt['full'])}") + print(f" HOLD-OUT {fmt(bt['holdout'])}") + + bh = buy_hold_daily() + print("\n BENCHMARK buy&hold 50/50 (1d):") + print(f" FULL {fmt(metrics(bh))}") + print(f" HOLD-OUT {fmt(metrics(bh[bh.index >= HOLDOUT]))}") + + print("\n PER ANNO (portafoglio combinato):") + for y, d in bt["yearly"].items(): + print(f" {y}: ret {d['ret']*100:>+7.1f}% DD {d['dd']*100:>5.1f}%") + + print("\n POSIZIONI CORRENTI (ultima barra chiusa):") + for name, pos in pf.current_positions().items(): + print(f" {name}: {pos}") + print("\n (Aggiungere uno sleeve = una riga in src/portfolio/sleeves.active_sleeves, dopo validazione.)") + + +if __name__ == "__main__": + main() diff --git a/src/portfolio/__init__.py b/src/portfolio/__init__.py new file mode 100644 index 0000000..5571f9f --- /dev/null +++ b/src/portfolio/__init__.py @@ -0,0 +1,9 @@ +"""Portafoglio di strategie (estensibile) — v2.0.0. + +Un portafoglio aggrega N SLEEVE indipendenti, ognuno = una strategia validata che produce una +serie di rendimenti netti CAUSALE e netto-fee. Gli sleeve si combinano per peso su una griglia +GIORNALIERA comune (grid unica per mixare TF diversi). Vedi src.portfolio.portfolio + sleeves. +""" +from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily + +__all__ = ["Sleeve", "StrategyPortfolio", "to_daily"] diff --git a/src/portfolio/portfolio.py b/src/portfolio/portfolio.py new file mode 100644 index 0000000..318231b --- /dev/null +++ b/src/portfolio/portfolio.py @@ -0,0 +1,111 @@ +"""PORTAFOGLIO DI STRATEGIE — contenitore estensibile (v2.0.0). + +Modello: ogni SLEEVE produce una serie di rendimenti netti per-barra (datetime-indexed, CAUSALE, +netto fee). Il portafoglio: + 1. porta ogni sleeve su una griglia GIORNALIERA comune (compounding intra-giorno) — così sleeve + a TF diversi (1d, 1h, ...) si combinano in modo coerente; + 2. combina per PESO (rinormalizzato a 1) sui giorni comuni a tutti gli sleeve; + 3. = portafoglio equal-capital-by-weight ribilanciato di continuo (interpretazione del weighted- + return combine). Equity = capitale · Π(1+combo). + +AGGIUNGERE uno sleeve è una riga in src/portfolio/sleeves.py (vedi lì il template). +Metriche oneste: FULL + HOLD-OUT 2025-26 (bloccato) + per-anno, e standalone per-sleeve. +""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Callable +import numpy as np +import pandas as pd + +DAYS_PER_YEAR = 365.25 +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") + + +def to_daily(net: pd.Series) -> pd.Series: + """Compound una serie di rendimenti netti per-barra a GIORNALIERA (griglia comune del portafoglio).""" + s = net.dropna().sort_index() + if not isinstance(s.index, pd.DatetimeIndex): + s.index = pd.to_datetime(s.index, utc=True) + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + return ((1.0 + s).resample("1D").prod() - 1.0).dropna() + + +@dataclass +class Sleeve: + """Una strategia nel portafoglio. daily_fn() -> serie rendimenti netti per-barra (causale, netto fee). + pos_fn() (opzionale) -> dict posizioni-bersaglio correnti, per introspezione live.""" + name: str + weight: float + daily_fn: Callable[[], pd.Series] + pos_fn: Callable[[], dict] | None = None + _cache: pd.Series | None = field(default=None, repr=False, compare=False) + + def daily(self) -> pd.Series: + if self._cache is None: + self._cache = to_daily(self.daily_fn()) + return self._cache + + +def metrics(daily: pd.Series) -> dict: + r = np.asarray(daily.dropna().values, float) + if len(r) < 2 or r.std() == 0: + return dict(sharpe=0.0, cagr=0.0, maxdd=0.0, ret=0.0, n=int(len(r))) + eq = np.cumprod(1.0 + r) + pk = np.maximum.accumulate(eq) + years = len(r) / DAYS_PER_YEAR + return dict(sharpe=float(r.mean() / r.std() * np.sqrt(DAYS_PER_YEAR)), + cagr=float(eq[-1] ** (1 / years) - 1) if years > 0 and eq[-1] > 0 else 0.0, + maxdd=float(np.max((pk - eq) / pk)), ret=float(eq[-1] - 1), n=int(len(r))) + + +def yearly(daily: pd.Series) -> dict: + out = {} + for y, g in daily.groupby(daily.index.year): + v = g.values + eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq) + out[int(y)] = dict(ret=float(eq[-1] - 1), dd=float(np.max((pk - eq) / pk))) + return out + + +class StrategyPortfolio: + def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0): + if not sleeves: + raise ValueError("portafoglio vuoto: serve almeno uno sleeve") + self.sleeves = sleeves + self.capital = capital + + def weights(self) -> dict: + tot = sum(s.weight for s in self.sleeves) + if tot <= 0: + raise ValueError("somma pesi non positiva") + return {s.name: s.weight / tot for s in self.sleeves} + + def combined_daily(self, lo=None, hi=None) -> pd.Series: + w = self.weights() + cols = {s.name: s.daily() for s in self.sleeves} + J = pd.concat(cols, axis=1, join="inner").dropna() + combo = sum(w[c] * J[c] for c in J.columns) + if lo is not None: + combo = combo[combo.index >= lo] + if hi is not None: + combo = combo[combo.index < hi] + return combo + + def backtest(self) -> dict: + full = self.combined_daily() + return dict( + weights=self.weights(), + full=metrics(full), + holdout=metrics(self.combined_daily(lo=HOLDOUT)), + yearly=yearly(full), + per_sleeve={s.name: dict(weight=self.weights()[s.name], + full=metrics(s.daily()), + holdout=metrics(s.daily()[s.daily().index >= HOLDOUT])) + for s in self.sleeves}, + equity=self.capital * np.cumprod(1.0 + full.values), + index=full.index, + ) + + def current_positions(self) -> dict: + return {s.name: (s.pos_fn() if s.pos_fn else None) for s in self.sleeves} diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py new file mode 100644 index 0000000..79f6a92 --- /dev/null +++ b/src/portfolio/sleeves.py @@ -0,0 +1,54 @@ +"""SLEEVE del portafoglio + REGISTRY degli sleeve attivi. + +Per AGGIUNGERE una strategia al portafoglio: + 1. Validala col gauntlet onesto (scripts/analysis/research_lab.py + hold-out + cross-asset). + 2. Scrivi una funzione `__returns() -> pd.Series` che ritorna i suoi rendimenti netti + per-barra (datetime-indexed, CAUSALE, netto fee). Deve passare il guard di causalità. + 3. Avvolgila in uno Sleeve(nome, peso, fn[, pos_fn]) e aggiungila a active_sleeves(). +Niente sleeve non validati: il portafoglio è solo per edge che reggono il gauntlet. +""" +from __future__ import annotations +import numpy as np +import pandas as pd + +from src.data.downloader import load_data +from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d, simple_returns +from src.portfolio.portfolio import Sleeve + +ASSETS = ("BTC", "ETH") + + +# ----------------------------- TP01 (PORT LF1d) ----------------------------- +def _tp01_returns() -> pd.Series: + """TP01: TSMOM vol-target long-flat, 50/50 BTC+ETH, a 1d (>=12h: vedi nota look-ahead nel modulo). + Rendimenti netti per-barra del portafoglio (causale: posizione decisa a close[i-1], tenuta in i).""" + tp = TrendPortfolio(**CANONICAL) + series = {} + for a in ASSETS: + df = resample_1d(load_data(a, "1h")) + r = simple_returns(df["close"].values.astype(float)) + tgt = tp.target_series(df) + held = np.zeros(len(tgt)); held[1:] = tgt[:-1] + net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0 + series[a] = pd.Series(np.clip(net, -0.99, None), index=pd.to_datetime(df["datetime"])) + 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 _tp01_positions() -> dict: + tp = TrendPortfolio(**CANONICAL) + return {a: round(tp.current_target(resample_1d(load_data(a, "1h"))), 4) for a in ASSETS} + + +def tp01_sleeve(weight: float = 1.0) -> Sleeve: + return Sleeve("TP01_trend_1d", weight, _tp01_returns, pos_fn=_tp01_positions) + + +# ----------------------------- REGISTRY ----------------------------- +def active_sleeves() -> list[Sleeve]: + """Sleeve ATTIVI nel portafoglio. Per ora solo TP01. Aggiungere qui le strategie validate.""" + return [ + tp01_sleeve(weight=1.0), + # --- TEMPLATE per il prossimo sleeve (dopo validazione col gauntlet) --- + # mystrat_sleeve(weight=1.0), + ] diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py new file mode 100644 index 0000000..14f6cc5 --- /dev/null +++ b/tests/test_portfolio.py @@ -0,0 +1,55 @@ +"""Test del contenitore portafoglio estensibile.""" +import sys +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np +import pandas as pd +import pytest + +from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics + + +def _const_sleeve(name, weight, val, n=400): + idx = pd.date_range("2020-01-01", periods=n, freq="1D", tz="UTC") + return Sleeve(name, weight, lambda: pd.Series(val, index=idx)) + + +def test_single_sleeve_equals_itself(): + s = _const_sleeve("A", 1.0, 0.001) + pf = StrategyPortfolio([s]) + combo = pf.combined_daily() + assert np.allclose(combo.values, s.daily().values) + assert pf.weights() == {"A": 1.0} + + +def test_weights_normalize(): + pf = StrategyPortfolio([_const_sleeve("A", 3.0, 0.001), _const_sleeve("B", 1.0, 0.002)]) + w = pf.weights() + assert abs(sum(w.values()) - 1.0) < 1e-12 + assert abs(w["A"] - 0.75) < 1e-12 and abs(w["B"] - 0.25) < 1e-12 + + +def test_equal_weight_combine(): + a, b = _const_sleeve("A", 1.0, 0.001), _const_sleeve("B", 1.0, 0.003) + pf = StrategyPortfolio([a, b]) + combo = pf.combined_daily() + assert np.allclose(combo.values, 0.5 * 0.001 + 0.5 * 0.003) # 0.002 + + +def test_to_daily_compounds_intraday(): + # due barre da +1% nello stesso giorno -> +2.01% giornaliero + idx = pd.to_datetime(["2020-01-01T00:00", "2020-01-01T12:00"], utc=True) + d = to_daily(pd.Series([0.01, 0.01], index=idx)) + assert len(d) == 1 and abs(d.iloc[0] - (1.01 * 1.01 - 1)) < 1e-12 + + +def test_metrics_basic(): + idx = pd.date_range("2020-01-01", periods=730, freq="1D", tz="UTC") + m = metrics(pd.Series(0.0005, index=idx)) # ritorno costante positivo + assert m["ret"] > 0 and m["maxdd"] == 0.0 and m["n"] == 730 + + +def test_empty_portfolio_raises(): + with pytest.raises(ValueError): + StrategyPortfolio([])