feat(portfolio): contenitore di strategie ESTENSIBILE — TP01 primo sleeve
src/portfolio/: Sleeve (serie rendimenti netti per-barra, causale/fee-aware) + StrategyPortfolio (combina N sleeve per peso su griglia giornaliera comune, metriche FULL/HOLD-OUT/per-anno + standalone per-sleeve, vs buy&hold). Registry sleeve attivi in sleeves.py: per ora SOLO TP01 (peso 100%); aggiungere = una riga (dopo validazione col gauntlet). Report (run_portfolio.py): TP01 FULL Sh 1.30 / DD 14.3% / ~€1.52/g, HOLD-OUT 0.31 / +3.5% (buy&hold -0.32 / -39%). Posizione corrente flat (difensivo). tests/test_portfolio.py (6 test). CLAUDE.md aggiornato (struttura + comando + come aggiungere uno sleeve). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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 `_<nome>_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),
|
||||
]
|
||||
@@ -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([])
|
||||
Reference in New Issue
Block a user