Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
45 KiB
Cartella portfolios/ — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Introdurre portafogli come oggetti di prima classe (capitale-pool condiviso) capaci sia di backtest/report sia di gestione live (sizing, ribilancio, ledger PnL), usando il data layer Cerbero v2.
Architecture: Una classe Portfolio (definizione: sleeve + schema pesi) con due facce sulla stessa definizione: backtest() riusa l'unico builder di equity-per-sleeve esistente (parità per costruzione col report); il live (PortfolioRunner) costruisce i worker esistenti come esecutori, alloca peso×capitale, ribilancia giornalmente e aggrega nel PortfolioLedger. Codice nuovo in src/portfolio/, definizioni concrete in scripts/portfolios/, config live in portfolios.yml.
Tech Stack: Python 3.11, uv, pandas/numpy, scipy (clustering già usato), pytest, requests (Cerbero MCP v2). Riusa scripts/analysis/{combine_portfolio,report_families,pairs_research,tsmom_research,shape_ml_validate}.py e src/live/{multi_runner,strategy_worker,pairs_worker,cerbero_client}.py.
Spec di riferimento: docs/superpowers/specs/2026-05-29-portfolios-design.md
File structure
| File | Responsabilità |
|---|---|
src/portfolio/__init__.py |
package marker |
src/portfolio/sleeves.py |
all_sleeve_equities() — unico builder di equity-per-sleeve (delega a report_families.build_everything), sleeve_returns_df() |
src/portfolio/weighting.py |
family_of, equal, manual, cap, inverse_vol, cluster_rp, weight_vector |
src/portfolio/base.py |
SleeveSpec, PortfolioResult, Portfolio (.backtest(), .weight_vector()) |
src/portfolio/ledger.py |
PortfolioLedger (capitale, alloc, equity, PnL, peak/DD, persistenza/resume) |
src/portfolio/runner.py |
PortfolioRunner (live: data v2, build worker, sizing, ribilancio, aggregazione) |
src/live/cerbero_client.py |
modifica: aggiunge metodi v2 get_historical_v2, get_instruments, get_ticker_batch |
scripts/portfolios/PORT0{1..6}_*.py |
definizioni concrete + run() report |
portfolios.yml |
config live (portafoglio attivo, capitale, pesi, cap, leva, cadenza) |
tests/portfolio/test_*.py |
unit + parità + smoke |
Sleeve id canonici (devono combaciare con le chiavi di build_everything per la parità):
MR01_BTC MR02_BTC MR07_BTC MR01_ETH MR02_ETH MR07_ETH · DIP01_BTC TR01_basket ROT02_rot · PR_ETHBTC PR_LTCETH PR_ADAETH PR_BTCLTC PR_ETHSOL · TSM01 · SH_BTC SH_ETH.
Task 1: Metodi Cerbero v2 nel client
Files:
-
Modify:
src/live/cerbero_client.py -
Test:
tests/portfolio/test_cerbero_v2.py(richiede rete; marcatonetwork) -
Step 1: Crea il package di test
mkdir -p tests/portfolio && touch tests/portfolio/__init__.py
- Step 2: Aggiungi i metodi v2 al client
In src/live/cerbero_client.py, dentro la classe CerberoClient, dopo get_historical (riga ~50) aggiungi:
def get_historical_v2(self, instrument: str, start_date: str, end_date: str,
interval: str = "1h", exchange: str = "deribit") -> list[dict]:
"""Endpoint unificato v2: /mcp/tools/get_historical (exchange deribit|hyperliquid).
Stesso shape candele del legacy: [{timestamp(ms), open, high, low, close, volume}]."""
data = self._post("/mcp/tools/get_historical", {
"exchange": exchange, "instrument": instrument,
"interval": interval, "start_date": start_date, "end_date": end_date,
})
return data.get("candles", [])
def get_instruments(self, currency: str, kind: str = "future",
exchange: str = "deribit", limit: int = 100) -> list[dict]:
"""Enumera gli strumenti reali (v2). Usato per risolvere il naming senza hardcoding."""
data = self._post("/mcp/tools/get_instruments", {
"exchange": exchange, "currency": currency, "kind": kind, "limit": limit,
})
return data.get("instruments", data if isinstance(data, list) else [])
def get_ticker_batch(self, instruments: list[str]) -> dict:
"""Prezzi correnti di N strumenti in una sola chiamata (v2, Deribit)."""
return self._post("/mcp-deribit/tools/get_ticker_batch", {"instruments": instruments})
- Step 3: Scrivi lo smoke test di rete
tests/portfolio/test_cerbero_v2.py:
import pytest
from src.live.cerbero_client import CerberoClient
@pytest.mark.network
def test_get_historical_v2_shape():
cli = CerberoClient()
candles = cli.get_historical_v2("BTC-PERPETUAL", "2026-05-25", "2026-05-27", "1h")
assert len(candles) > 0
c0 = candles[0]
assert {"timestamp", "open", "high", "low", "close", "volume"} <= set(c0)
@pytest.mark.network
def test_get_instruments_returns_list():
cli = CerberoClient()
inst = cli.get_instruments("ETH", "future")
assert isinstance(inst, list) and len(inst) > 0
- Step 4: Esegui (con rete)
Run: uv run pytest tests/portfolio/test_cerbero_v2.py -v -m network
Expected: 2 passed (se la rete/token è disponibile). Senza rete: uv run pytest -m "not network" li salta.
- Step 5: Registra il marker
network
In pyproject.toml, sotto [tool.pytest.ini_options] (riga ~27) aggiungi:
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
- Step 6: Commit
git add src/live/cerbero_client.py tests/portfolio/ pyproject.toml
git commit -m "feat(portfolio): metodi Cerbero v2 (get_historical_v2, get_instruments, get_ticker_batch)"
Task 2: Schemi di peso (weighting.py)
Files:
-
Create:
src/portfolio/__init__.py,src/portfolio/weighting.py -
Test:
tests/portfolio/test_weighting.py -
Step 1: Crea il package
mkdir -p src/portfolio && touch src/portfolio/__init__.py
- Step 2: Scrivi i test (falliscono)
tests/portfolio/test_weighting.py:
import numpy as np
import pandas as pd
import pytest
from src.portfolio import weighting as W
def test_family_of():
assert W.family_of("PR_ETHBTC") == "PAIRS"
assert W.family_of("SH_BTC") == "SHAPE"
assert W.family_of("TSM01") == "TSM"
assert W.family_of("MR01_BTC") == "FADE"
assert W.family_of("DIP01_BTC") == "HONEST"
def test_equal_sums_to_one():
w = W.equal(["a", "b", "c", "d"])
assert pytest.approx(sum(w.values())) == 1.0
assert all(abs(v - 0.25) < 1e-9 for v in w.values())
def test_manual_normalizes():
w = W.manual(["a", "b"], {"a": 3, "b": 1})
assert pytest.approx(w["a"]) == 0.75 and pytest.approx(w["b"]) == 0.25
def test_cap_limits_family_and_redistributes():
ids = ["PR_ETHBTC", "PR_LTCETH", "MR01_BTC", "MR02_BTC"]
w = W.cap(ids, caps={"PAIRS": 0.30})
pairs_w = w["PR_ETHBTC"] + w["PR_LTCETH"]
assert pytest.approx(pairs_w, abs=1e-9) == 0.30 # cap rispettato
assert pytest.approx(sum(w.values())) == 1.0 # resto ridistribuito
assert w["MR01_BTC"] > 0.25 # non-pairs sovrappesati
def test_inverse_vol_prefers_low_vol():
idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
rng = np.random.default_rng(0)
df = pd.DataFrame({"lo": rng.normal(0, 0.01, 100), "hi": rng.normal(0, 0.05, 100)}, index=idx)
w = W.inverse_vol(["lo", "hi"], df, lookback=90)
assert w["lo"] > w["hi"]
assert pytest.approx(sum(w.values())) == 1.0
- Step 3: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_weighting.py -v
Expected: FAIL (ModuleNotFoundError: weighting).
- Step 4: Implementa
weighting.py
"""Schemi di peso per i portafogli. Ogni funzione ritorna {sleeve_id: peso} con somma 1."""
from __future__ import annotations
import numpy as np
import pandas as pd
_PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE")]
def family_of(sleeve_id: str) -> str:
for pre, fam in _PREFIX:
if sleeve_id.startswith(pre):
return fam
return "HONEST"
def _normalize(w: dict[str, float]) -> dict[str, float]:
tot = sum(w.values())
return {k: (v / tot if tot > 0 else 0.0) for k, v in w.items()}
def equal(ids: list[str]) -> dict[str, float]:
n = len(ids)
return {i: 1.0 / n for i in ids} if n else {}
def manual(ids: list[str], weights: dict[str, float]) -> dict[str, float]:
return _normalize({i: float(weights.get(i, 0.0)) for i in ids})
def cap(ids: list[str], caps: dict[str, float]) -> dict[str, float]:
"""Equal-weight con tetto al peso AGGREGATO di una famiglia; l'eccesso ridistribuito
pro-quota alle famiglie non cappate (iterativo finché tutti i cap sono rispettati)."""
w = equal(ids)
fam = {i: family_of(i) for i in ids}
for _ in range(10):
over = {}
for f, lim in caps.items():
members = [i for i in ids if fam[i] == f]
cur = sum(w[i] for i in members)
if cur > lim + 1e-12 and members:
over[f] = (members, lim, cur)
if not over:
break
free_ids = [i for i in ids if fam[i] not in caps]
freed = 0.0
for f, (members, lim, cur) in over.items():
scale = lim / cur
for i in members:
freed += w[i] * (1 - scale)
w[i] *= scale
if free_ids and freed > 0:
add = freed / len(free_ids)
for i in free_ids:
w[i] += add
else:
break
return _normalize(w)
def inverse_vol(ids: list[str], returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
sub = returns_df[ids].iloc[-lookback:]
vol = sub.std()
inv = {i: (1.0 / vol[i] if vol[i] and vol[i] > 0 else 0.0) for i in ids}
return _normalize(inv)
def cluster_rp(ids: list[str], clusters: dict[str, str],
returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
"""Equal fra i cluster naturali, poi inverse-vol dentro ogni cluster."""
groups: dict[str, list[str]] = {}
for i in ids:
groups.setdefault(clusters.get(i, i), []).append(i)
per = 1.0 / len(groups) if groups else 0.0
w: dict[str, float] = {}
for members in groups.values():
iv = inverse_vol(members, returns_df, lookback)
for i in members:
w[i] = per * iv[i]
return _normalize(w)
def weight_vector(scheme: str, ids: list[str], returns_df: pd.DataFrame | None = None,
*, weights: dict | None = None, caps: dict | None = None,
clusters: dict | None = None, lookback: int = 90) -> dict[str, float]:
if scheme == "equal":
return equal(ids)
if scheme == "manual":
return manual(ids, weights or {})
if scheme == "cap":
return cap(ids, caps or {})
if scheme == "inverse_vol":
return inverse_vol(ids, returns_df, lookback)
if scheme == "cluster_rp":
return cluster_rp(ids, clusters or {}, returns_df, lookback)
raise ValueError(f"schema peso sconosciuto: {scheme}")
- Step 5: Run — verifica passaggio
Run: uv run pytest tests/portfolio/test_weighting.py -v
Expected: 5 passed.
- Step 6: Commit
git add src/portfolio/__init__.py src/portfolio/weighting.py tests/portfolio/test_weighting.py
git commit -m "feat(portfolio): schemi di peso (equal/manual/cap/inverse_vol/cluster_rp)"
Task 3: Builder unificato delle equity-per-sleeve (sleeves.py)
Files:
-
Create:
src/portfolio/sleeves.py -
Test:
tests/portfolio/test_sleeves.py -
Step 1: Scrivi il test (fallisce)
tests/portfolio/test_sleeves.py:
import pandas as pd
from src.portfolio import sleeves as S
ALL_IDS = {"MR01_BTC", "MR02_BTC", "MR07_BTC", "MR01_ETH", "MR02_ETH", "MR07_ETH",
"DIP01_BTC", "TR01_basket", "ROT02_rot",
"PR_ETHBTC", "PR_LTCETH", "PR_ADAETH", "PR_BTCLTC", "PR_ETHSOL",
"TSM01", "SH_BTC", "SH_ETH"}
def test_all_sleeve_equities_keys_and_index():
eq = S.all_sleeve_equities()
assert ALL_IDS <= set(eq)
s = eq["MR01_BTC"]
assert isinstance(s, pd.Series) and len(s) > 100
assert str(s.index.tz) == "UTC"
def test_returns_df_aligned():
df = S.sleeve_returns_df(["MR01_BTC", "PR_ETHBTC", "SH_BTC"])
assert list(df.columns) == ["MR01_BTC", "PR_ETHBTC", "SH_BTC"]
assert df.isna().sum().sum() == 0
- Step 2: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_sleeves.py -v
Expected: FAIL (ModuleNotFoundError: sleeves).
- Step 3: Implementa
sleeves.py
"""Unico builder delle equity GIORNALIERE per sleeve (fonte di verità del backtest).
Delega a scripts/analysis/report_families.build_everything (che a sua volta usa
combine_portfolio + pairs_research + tsmom_research + shape_ml_validate), così le
metriche del Portfolio coincidono per costruzione con report_families."""
from __future__ import annotations
import pandas as pd
_CACHE: dict[str, pd.Series] | None = None
def all_sleeve_equities() -> dict[str, pd.Series]:
"""{sleeve_id: equity giornaliera normalizzata su IDX comune}. Cache di processo."""
global _CACHE
if _CACHE is None:
from scripts.analysis.report_families import build_everything
S, pairs, tsm, shape = build_everything()
_CACHE = {**S, **pairs, **tsm, **shape}
return _CACHE
def sleeve_returns_df(ids: list[str]) -> pd.DataFrame:
"""Rendimenti giornalieri allineati per gli sleeve richiesti."""
eq = all_sleeve_equities()
return pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
- Step 4: Run — verifica passaggio
Run: uv run pytest tests/portfolio/test_sleeves.py -v
Expected: 2 passed (richiede i parquet in data/raw/; ~2-3 min per la build).
- Step 5: Commit
git add src/portfolio/sleeves.py tests/portfolio/test_sleeves.py
git commit -m "feat(portfolio): builder unificato equity-per-sleeve (parità con report_families)"
Task 4: SleeveSpec, Portfolio, PortfolioResult + backtest (base.py)
Files:
-
Create:
src/portfolio/base.py -
Test:
tests/portfolio/test_backtest_parity.py -
Step 1: Scrivi il test di parità (fallisce)
tests/portfolio/test_backtest_parity.py:
import pytest
from src.portfolio.base import Portfolio, SleeveSpec
from scripts.analysis.report_families import build_everything
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
def _master9_specs():
fade = [SleeveSpec(kind="single", name=f"{c}", sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev")
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
honest = [SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev"),
SleeveSpec(kind="single", name="TR01", sid="TR01_basket", cluster="trend"),
SleeveSpec(kind="single", name="ROT02", sid="ROT02_rot", cluster="rotation")]
return fade + honest
def test_master9_backtest_matches_report():
p = Portfolio(code="PORT03", label="Master", sleeves=_master9_specs(), weighting="equal")
res = p.backtest()
# riferimento: equal-weight degli stessi 9 sleeve via la macchina del report
S, _, _, _ = build_everything()
dr_ref = port_returns(S)
ref_full, ref_oos = metrics(dr_ref), metrics(dr_ref, lo=SPLIT)
assert res.full["sharpe"] == pytest.approx(ref_full["sharpe"], abs=1e-6)
assert res.full["dd"] == pytest.approx(ref_full["dd"], abs=1e-6)
assert res.oos["sharpe"] == pytest.approx(ref_oos["sharpe"], abs=1e-6)
- Step 2: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_backtest_parity.py -v
Expected: FAIL (ModuleNotFoundError: base).
- Step 3: Implementa
base.py
"""Portfolio: definizione (sleeve + schema pesi) con faccia di backtest.
La faccia live è in runner.py."""
from __future__ import annotations
from dataclasses import dataclass, field
import pandas as pd
from src.portfolio import weighting as W
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
from scripts.analysis.combine_portfolio import port_returns, metrics, yearly_returns, SPLIT
@dataclass
class SleeveSpec:
kind: str # "single" | "pairs" | "ml"
name: str # codice strategia per il live (MR01/DIP01/PR01.../SH01)
sid: str # id canonico (= chiave in all_sleeve_equities)
asset: str | None = None
a: str | None = None
b: str | None = None
tf: str = "1h"
params: dict = field(default_factory=dict)
cluster: str = ""
@dataclass
class PortfolioResult:
code: str
weights: dict
full: dict # ret/cagr/dd/sharpe (FULL)
oos: dict # ret/cagr/dd/sharpe (OOS)
yearly: dict # anno -> ret%
risk: dict # sid -> % contributo al rischio (equal informativo)
@dataclass
class Portfolio:
code: str
label: str
sleeves: list[SleeveSpec]
weighting: str = "equal"
weights: dict | None = None
caps: dict | None = None
total_capital: float = 1000.0
leverage: float = 3.0
rebalance: str = "1D"
vol_lookback: int = 90
@property
def sleeve_ids(self) -> list[str]:
return [s.sid for s in self.sleeves]
@property
def clusters(self) -> dict[str, str]:
return {s.sid: (s.cluster or s.sid) for s in self.sleeves}
def weight_vector(self, returns_df: pd.DataFrame | None = None) -> dict[str, float]:
return W.weight_vector(
self.weighting, self.sleeve_ids, returns_df,
weights=self.weights, caps=self.caps,
clusters=self.clusters, lookback=self.vol_lookback,
)
def backtest(self) -> PortfolioResult:
eq = all_sleeve_equities()
members = {sid: eq[sid] for sid in self.sleeve_ids}
dr = sleeve_returns_df(self.sleeve_ids)
w = self.weight_vector(dr)
port_dr = port_returns(members, w)
full, oos = metrics(port_dr), metrics(port_dr, lo=SPLIT)
# contributo al rischio (equal-weight, informativo)
cov = dr.cov().values
import numpy as np
we = np.ones(len(self.sleeve_ids)) / len(self.sleeve_ids)
pv = float(we @ cov @ we)
rc = we * (cov @ we)
risk = {sid: float(rc[k] / pv * 100) if pv > 0 else 0.0
for k, sid in enumerate(self.sleeve_ids)}
return PortfolioResult(self.code, w, full, oos, yearly_returns(port_dr), risk)
- Step 4: Run — verifica passaggio
Run: uv run pytest tests/portfolio/test_backtest_parity.py -v
Expected: 1 passed.
- Step 5: Commit
git add src/portfolio/base.py tests/portfolio/test_backtest_parity.py
git commit -m "feat(portfolio): SleeveSpec/Portfolio/backtest con parità verso report_families"
Task 5: Definizioni concrete scripts/portfolios/PORT01..06
Files:
-
Create:
scripts/portfolios/__init__.py,scripts/portfolios/_defs.py,PORT01..06_*.py -
Test:
tests/portfolio/test_definitions.py -
Step 1: Test (fallisce)
tests/portfolio/test_definitions.py:
from scripts.portfolios._defs import PORTFOLIOS
def test_six_portfolios_defined():
assert set(PORTFOLIOS) == {"PORT01", "PORT02", "PORT03", "PORT04", "PORT05", "PORT06"}
def test_port06_is_master_shape_cap():
p = PORTFOLIOS["PORT06"]
sids = set(p.sleeve_ids)
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC"} <= sids
assert len(sids) == 17
assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33}
def test_default_leverage_sober():
assert PORTFOLIOS["PORT06"].leverage == 2.0
- Step 2: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_definitions.py -v
Expected: FAIL (ModuleNotFoundError: _defs).
- Step 3: Crea il package e le definizioni condivise
touch scripts/portfolios/__init__.py
scripts/portfolios/_defs.py:
"""Definizioni canoniche dei portafogli (tutti i tipi visti finora)."""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.base import Portfolio, SleeveSpec # noqa: E402
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev")
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
HONEST = [
SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev"),
SleeveSpec(kind="single", name="TR01", sid="TR01_basket", cluster="trend"),
SleeveSpec(kind="single", name="ROT02", sid="ROT02_rot", cluster="rotation"),
]
PAIRS = [
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_LTCETH", a="LTC", b="ETH", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_ADAETH", a="ADA", b="ETH", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_BTCLTC", a="BTC", b="LTC", cluster="BTC-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHSOL", a="ETH", b="SOL", cluster="ETH-rev"),
]
TSM = [SleeveSpec(kind="single", name="TSM01", sid="TSM01", cluster="trend")]
SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape")
for a in ("BTC", "ETH")]
PORTFOLIOS = {
"PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"),
"PORT02": Portfolio("PORT02", "Fade master", FADE, weighting="equal"),
"PORT03": Portfolio("PORT03", "Master", FADE + HONEST, weighting="equal"),
"PORT04": Portfolio("PORT04", "Master + pairs", FADE + HONEST + PAIRS,
weighting="cap", caps={"PAIRS": 0.33}),
"PORT05": Portfolio("PORT05", "Master esteso", FADE + HONEST + PAIRS + TSM,
weighting="cap", caps={"PAIRS": 0.33}),
"PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE,
weighting="cap", caps={"PAIRS": 0.33}, leverage=2.0),
}
- Step 4: Crea i 6 script con
run()(report)
Per ciascun code in PORT01..PORT06, crea scripts/portfolios/<code>_<slug>.py. Esempio scripts/portfolios/PORT06_master_shape.py:
"""PORT06 — Master + shape (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT06"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code} — {p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
Gli altri 5 file sono identici cambiando solo CODE (PORT01..PORT05) e il nome file:
PORT01_honest.py, PORT02_fade.py, PORT03_master.py, PORT04_master_pairs.py, PORT05_master_esteso.py.
- Step 5: Run test + uno smoke report
Run: uv run pytest tests/portfolio/test_definitions.py -v
Expected: 3 passed.
Run: uv run python scripts/portfolios/PORT06_master_shape.py
Expected: stampa FULL/OOS/per-anno coerenti col report (Sharpe FULL ~6, OOS più alto).
- Step 6: Commit
git add scripts/portfolios/ tests/portfolio/test_definitions.py
git commit -m "feat(portfolio): definizioni PORT01-06 + report run() (default PORT06)"
Task 6: PortfolioLedger (stato/PnL/persistenza)
Files:
-
Create:
src/portfolio/ledger.py -
Test:
tests/portfolio/test_ledger.py -
Step 1: Test (fallisce)
tests/portfolio/test_ledger.py:
from pathlib import Path
from src.portfolio.ledger import PortfolioLedger
def test_alloc_split_by_weights(tmp_path):
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
alloc = L.allocate({"a": 0.6, "b": 0.4})
assert alloc == {"a": 600.0, "b": 400.0}
def test_update_tracks_equity_and_dd(tmp_path):
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
L.update_equity({"a": 700.0, "b": 500.0}) # equity 1200
assert L.equity == 1200.0 and L.peak == 1200.0 and L.max_dd == 0.0
L.update_equity({"a": 500.0, "b": 400.0}) # equity 900 -> dd 25%
assert L.equity == 900.0
assert abs(L.max_dd - 25.0) < 1e-9
def test_persist_and_resume(tmp_path):
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
L.update_equity({"a": 1100.0})
L.save()
L2 = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
assert L2.equity == 1100.0 and L2.peak == 1100.0
assert (tmp_path / "PORTX" / "equity.jsonl").exists()
- Step 2: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_ledger.py -v
Expected: FAIL (ModuleNotFoundError: ledger).
- Step 3: Implementa
ledger.py
"""Ledger aggregato del portafoglio: capitale, allocazioni, equity, PnL, peak/DD, persistenza."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
class PortfolioLedger:
def __init__(self, code: str, total_capital: float = 1000.0,
data_dir: Path = Path("data/portfolios")):
self.code = code
self.initial_capital = total_capital
self.total_capital = total_capital
self.work_dir = Path(data_dir) / code
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.equity_path = self.work_dir / "equity.jsonl"
self.events_path = self.work_dir / "events.jsonl"
self.equity = total_capital
self.peak = total_capital
self.max_dd = 0.0
self.weights: dict[str, float] = {}
self.alloc: dict[str, float] = {}
self.last_rebalance = ""
self._load()
def _load(self):
if not self.status_path.exists():
return
s = json.loads(self.status_path.read_text())
self.total_capital = s.get("total_capital", self.total_capital)
self.equity = s.get("equity", self.equity)
self.peak = s.get("peak", self.peak)
self.max_dd = s.get("max_dd", self.max_dd)
self.weights = s.get("weights", {})
self.alloc = s.get("alloc", {})
self.last_rebalance = s.get("last_rebalance", "")
def allocate(self, weights: dict[str, float]) -> dict[str, float]:
self.weights = dict(weights)
self.alloc = {sid: round(self.total_capital * w, 6) for sid, w in weights.items()}
self.last_rebalance = datetime.now(timezone.utc).isoformat()
self._append(self.events_path, {"event": "rebalance", "weights": self.weights,
"total_capital": self.total_capital})
return self.alloc
def update_equity(self, sleeve_equity: dict[str, float], pnl_day: float = 0.0):
self.equity = float(sum(sleeve_equity.values()))
if self.equity > self.peak:
self.peak = self.equity
dd = (self.peak - self.equity) / self.peak * 100 if self.peak > 0 else 0.0
self.max_dd = max(self.max_dd, dd)
self._append(self.equity_path, {
"ts": datetime.now(timezone.utc).isoformat(),
"equity": round(self.equity, 2), "dd": round(dd, 3),
"pnl_day": round(pnl_day, 2),
"pnl_total": round(self.equity - self.initial_capital, 2),
})
def save(self):
self.status_path.write_text(json.dumps({
"code": self.code, "total_capital": round(self.total_capital, 2),
"equity": round(self.equity, 2), "peak": round(self.peak, 2),
"max_dd": round(self.max_dd, 3), "weights": self.weights,
"alloc": self.alloc, "last_rebalance": self.last_rebalance,
"ts": datetime.now(timezone.utc).isoformat(),
}, indent=2))
@staticmethod
def _append(path: Path, row: dict):
with open(path, "a") as f:
f.write(json.dumps(row) + "\n")
- Step 4: Run — verifica passaggio
Run: uv run pytest tests/portfolio/test_ledger.py -v
Expected: 3 passed.
- Step 5: Commit
git add src/portfolio/ledger.py tests/portfolio/test_ledger.py
git commit -m "feat(portfolio): PortfolioLedger (alloc, equity/DD, persistenza+resume)"
Task 7: portfolios.yml + loader della config live
Files:
-
Create:
portfolios.yml -
Modify:
src/portfolio/base.py(aggiungePortfolio.from_active_config) -
Test:
tests/portfolio/test_config.py -
Step 1: Crea
portfolios.yml
# Config LIVE del paper trader a portafoglio. Seleziona UN portafoglio attivo
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
active: PORT06 # default raccomandato: master + shape
overrides:
total_capital: 1000
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual
caps: {PAIRS: 0.33}
leverage: 2 # sobrio per il live reale
rebalance: 1D
poll_seconds: 60
- Step 2: Test (fallisce)
tests/portfolio/test_config.py:
from src.portfolio.base import load_active_portfolio
def test_load_active_applies_overrides(tmp_path):
cfg = tmp_path / "portfolios.yml"
cfg.write_text("active: PORT06\noverrides:\n leverage: 2\n total_capital: 500\n")
p = load_active_portfolio(cfg)
assert p.code == "PORT06"
assert p.leverage == 2.0
assert p.total_capital == 500
- Step 3: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_config.py -v
Expected: FAIL (ImportError: load_active_portfolio).
- Step 4: Implementa il loader in
base.py
Aggiungi in fondo a src/portfolio/base.py:
def load_active_portfolio(config_path) -> "Portfolio":
"""Carica il portafoglio attivo da portfolios.yml applicando gli override."""
import yaml
from pathlib import Path
from scripts.portfolios._defs import PORTFOLIOS
cfg = yaml.safe_load(Path(config_path).read_text())
p = PORTFOLIOS[cfg["active"]]
ov = cfg.get("overrides", {})
for k in ("total_capital", "weighting", "caps", "leverage", "rebalance", "vol_lookback"):
if k in ov and ov[k] is not None:
setattr(p, k, ov[k])
return p
- Step 5: Run — verifica passaggio
Run: uv run pytest tests/portfolio/test_config.py -v
Expected: 1 passed.
- Step 6: Commit
git add portfolios.yml src/portfolio/base.py tests/portfolio/test_config.py
git commit -m "feat(portfolio): portfolios.yml + load_active_portfolio (override operativi)"
Task 8: PortfolioRunner — costruzione worker + sizing pool
Files:
-
Create:
src/portfolio/runner.py -
Test:
tests/portfolio/test_runner_build.py -
Step 1: Test (fallisce)
tests/portfolio/test_runner_build.py:
from src.portfolio.runner import build_worker_for
from src.portfolio.base import SleeveSpec
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
def test_build_single_worker_capital_from_alloc(tmp_path):
spec = SleeveSpec(kind="single", name="MR01", sid="MR01_BTC", asset="BTC",
params={"bb_window": 50, "k": 2.5, "sl_atr": 2.0, "max_bars": 24})
w = build_worker_for(spec, alloc_capital=300.0, leverage=2.0, data_dir=tmp_path)
assert isinstance(w, StrategyWorker)
assert w.capital == 300.0 and w.leverage == 2.0
def test_build_pairs_worker(tmp_path):
spec = SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC",
params={"n": 50, "z_in": 2.0, "z_exit": 0.75, "max_bars": 72})
w = build_worker_for(spec, alloc_capital=200.0, leverage=2.0, data_dir=tmp_path)
assert isinstance(w, PairsWorker)
assert w.capital == 200.0
- Step 2: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_runner_build.py -v
Expected: FAIL (ModuleNotFoundError: runner).
- Step 3: Implementa la parte di build in
runner.py
"""PortfolioRunner: faccia live del portafoglio (capitale pool, sizing, ribilancio, ledger).
Riusa i worker esistenti come esecutori e il data layer Cerbero v2."""
from __future__ import annotations
from pathlib import Path
from src.portfolio.base import SleeveSpec, Portfolio
from src.portfolio.ledger import PortfolioLedger
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
from src.live.multi_runner import MLWorkerWrapper
from src.live.strategy_loader import load_strategy
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/
_STRAT_MODULE = {
"MR01": "MR01_bollinger_fade", "MR02": "MR02_donchian_fade",
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
# DIP01/TR01/ROT02 sono honest a sé: vedi nota nel design (worker dedicati in fase 2)
}
DATA_DIR = Path("data/paper_trades")
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
data_dir: Path = DATA_DIR, position_size: float = 0.15):
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata."""
if spec.kind == "pairs":
return PairsWorker(
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
capital=alloc_capital, position_size=position_size, leverage=leverage,
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
)
module = _STRAT_MODULE.get(spec.name)
if module is None:
raise ValueError(f"sleeve live non ancora supportato: {spec.name} "
f"(honest DIP01/TR01/ROT02 richiedono worker dedicati, fase 2)")
strategy = load_strategy(module)
worker = StrategyWorker(
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
)
if spec.kind == "ml": # SH01: retraining periodico
return MLWorkerWrapper(worker, {"retrain_hours": 24})
return worker
- Step 4: Run — verifica passaggio
Run: uv run pytest tests/portfolio/test_runner_build.py -v
Expected: 2 passed.
- Step 5: Commit
git add src/portfolio/runner.py tests/portfolio/test_runner_build.py
git commit -m "feat(portfolio): build_worker_for (worker esecutori con capitale da alloc pool)"
Task 9: PortfolioRunner — loop live (data v2, ribilancio, aggregazione)
Files:
-
Modify:
src/portfolio/runner.py -
Test:
tests/portfolio/test_runner_rebalance.py,scripts/analysis/smoke_portfolio.py -
Step 1: Test del ribilancio (fallisce)
tests/portfolio/test_runner_rebalance.py:
from src.portfolio.runner import rebalance_allocations
from src.portfolio.ledger import PortfolioLedger
def test_rebalance_resizes_to_total(tmp_path):
L = PortfolioLedger("PX", total_capital=1000.0, data_dir=tmp_path)
class FakeWorker:
def __init__(self, cap): self.capital = cap
workers = {"a": FakeWorker(700.0), "b": FakeWorker(500.0)} # equity 1200
rebalance_allocations(L, workers, {"a": 0.5, "b": 0.5})
assert L.total_capital == 1200.0
assert workers["a"].capital == 600.0 and workers["b"].capital == 600.0
- Step 2: Run — verifica fallimento
Run: uv run pytest tests/portfolio/test_runner_rebalance.py -v
Expected: FAIL (ImportError: rebalance_allocations).
- Step 3: Implementa ribilancio + loop in
runner.py
Aggiungi a src/portfolio/runner.py:
def _worker_equity(w) -> float:
inner = getattr(w, "worker", w) # smonta MLWorkerWrapper
return float(getattr(inner, "capital", 0.0))
def rebalance_allocations(ledger: PortfolioLedger, workers: dict, weights: dict[str, float]):
"""Ribilancio: total_capital = Σ equity sleeve; riallinea il capitale-base di ogni worker
a peso×total. Le posizioni APERTE restano sul loro notional (approssimazione dichiarata)."""
ledger.total_capital = sum(_worker_equity(w) for w in workers.values())
alloc = ledger.allocate(weights)
for sid, w in workers.items():
inner = getattr(w, "worker", w)
inner.capital = alloc.get(sid, inner.capital)
ledger.save()
def run(config_path: str = "portfolios.yml"):
"""Loop live a portafoglio. Data layer Cerbero v2; ribilancio a fine giornata UTC."""
import time
from datetime import datetime, timezone, timedelta
import pandas as pd
from src.portfolio.base import load_active_portfolio
from src.portfolio.sleeves import sleeve_returns_df
from src.portfolio.weighting import weight_vector
from src.live.cerbero_client import CerberoClient
p: Portfolio = load_active_portfolio(config_path)
ledger = PortfolioLedger(p.code, total_capital=p.total_capital)
client = CerberoClient()
# pesi iniziali (vol-based dai rendimenti storici degli sleeve; statici per equal/cap/manual)
dr = sleeve_returns_df(p.sleeve_ids)
weights = p.weight_vector(dr)
alloc = ledger.allocate(weights)
# costruisci i worker esecutori con capitale = quota allocata
workers = {s.sid: build_worker_for(s, alloc[s.sid], p.leverage) for s in p.sleeves}
# risolvi i nomi strumento via get_instruments (fallback alla mappa legacy)
from src.live.multi_runner import INSTRUMENT_MAP
inst_map = dict(INSTRUMENT_MAP) # TODO opzionale: arricchire via client.get_instruments
last_day = ""
poll = 60
while True:
try:
# fetch candele (v2 unificato) per ogni asset/tf richiesto dagli sleeve
keys = set()
for s in p.sleeves:
if s.kind == "pairs":
keys.add((s.a, s.tf)); keys.add((s.b, s.tf))
else:
keys.add((s.asset, s.tf))
cache = {}
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
for asset, tf in keys:
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), tf)
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
cache[(asset, tf)] = df.sort_values("timestamp").reset_index(drop=True)
# tick di ogni worker (esecutore)
for s in p.sleeves:
w = workers[s.sid]
if s.kind == "pairs":
ka, kb = (s.a, s.tf), (s.b, s.tf)
if ka in cache and kb in cache:
w.tick(cache[ka], cache[kb])
else:
key = (s.asset, s.tf)
if key in cache:
inner = getattr(w, "worker", w)
if hasattr(w, "needs_training") and w.needs_training():
w.train(cache[key], hold=inner.hold_bars)
w.tick(cache[key])
# aggrega equity nel ledger
ledger.update_equity({sid: _worker_equity(w) for sid, w in workers.items()})
# ribilancio a cambio giorno UTC
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if today != last_day and last_day:
dr = sleeve_returns_df(p.sleeve_ids)
rebalance_allocations(ledger, workers, p.weight_vector(dr))
last_day = today
ledger.save()
except KeyboardInterrupt:
ledger.save()
print("shutdown")
break
except Exception as e:
print(f"[runner] errore: {e}")
time.sleep(poll)
if __name__ == "__main__":
run()
- Step 4: Run — verifica passaggio
Run: uv run pytest tests/portfolio/test_runner_rebalance.py -v
Expected: 1 passed.
- Step 5: Smoke live (un tick reale, niente ordini)
scripts/analysis/smoke_portfolio.py:
"""Smoke reale: un giro di fetch v2 + build worker + un tick del portafoglio attivo.
NON apre ordini reali (paper). Verifica data layer v2 + sizing + ledger."""
import sys, shutil, tempfile
from pathlib import Path
from datetime import datetime, timezone, timedelta
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.base import load_active_portfolio
from src.portfolio.ledger import PortfolioLedger
from src.portfolio.runner import build_worker_for, _worker_equity
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
def main():
tmp = Path(tempfile.mkdtemp())
p = load_active_portfolio(PROJECT_ROOT / "portfolios.yml")
ledger = PortfolioLedger(p.code, total_capital=p.total_capital, data_dir=tmp)
alloc = ledger.allocate({s.sid: 1.0 / len(p.sleeves) for s in p.sleeves})
client = CerberoClient()
print(f"Portafoglio attivo: {p.code} ({p.label}) — {len(p.sleeves)} sleeve, leva {p.leverage}x")
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
ok = 0
for s in p.sleeves[:3]: # 3 sleeve campione per lo smoke
asset = s.asset or s.a
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), s.tf)
print(f" {s.sid:<12s} {inst:<18s} candele={len(candles)}")
ok += len(candles) > 0
print(f"OK: {ok}/3 sleeve con feed v2 fresco. Ledger equity iniziale={ledger.equity}")
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
Run: uv run python scripts/analysis/smoke_portfolio.py
Expected: stampa il portafoglio attivo (PORT06) e 3/3 sleeve con candele v2 > 0.
- Step 6: Commit
git add src/portfolio/runner.py tests/portfolio/test_runner_rebalance.py scripts/analysis/smoke_portfolio.py
git commit -m "feat(portfolio): PortfolioRunner live (data v2, tick, ribilancio giornaliero, ledger)"
Task 10: Documentazione (CLAUDE.md, README, comandi)
Files:
-
Modify:
CLAUDE.md,README.md -
Step 1: Aggiorna
CLAUDE.md
Nella struttura aggiungi src/portfolio/ e scripts/portfolios/; in "Comandi" aggiungi:
uv run python scripts/portfolios/PORT06_master_shape.py # report backtest portafoglio
uv run python -m src.portfolio.runner # paper trading a PORTAFOGLIO (capitale pool)
uv run python scripts/analysis/smoke_portfolio.py # smoke live data layer v2
Aggiungi una sezione "Portafogli" che riassume: oggetto Portfolio (pool, backtest+live), schemi pesi, default PORT06 (cap pairs 33%, leva 2x), data layer Cerbero v2, limite noto (posizioni aperte non travasate al ribilancio).
- Step 2: Aggiorna
README.md
Aggiungi la cartella portfolios/ alla struttura e una riga d'uso del nuovo paper trader a portafoglio. Prosa italiana completa (artefatto pubblico).
- Step 3: Esegui l'intera suite
Run: uv run pytest -m "not network" -v
Expected: tutti i test (weighting, sleeves, backtest_parity, definitions, ledger, config, runner_build, runner_rebalance) passano.
- Step 4: Commit
git add CLAUDE.md README.md
git commit -m "docs(portfolio): documenta cartella portfolios, comandi e default PORT06"
Self-review (svolta in fase di scrittura)
- Copertura spec: §3 layout → Task 2-9; §4 schema → Task 4; §5 backtest → Task 4-5; §6 live → Task 8-9; §7 persistenza → Task 6; §8 portafogli/default → Task 5,7; §9 test → ogni task TDD + suite finale; §2.6 data v2 → Task 1,9. Tutte coperte.
- Limite noto (posizioni aperte non travasate): implementato in
rebalance_allocationse documentato (Task 9 docstring + Task 10). - Honest DIP01/TR01/ROT02 nel live:
build_worker_forsolleva un errore esplicito (worker dedicati in fase 2) — coerente con lo scope: backtest li include, il live v1 esegue fade/pairs/shape che hanno worker pronti. Nota: se il default PORT06 deve girare live al primo colpo servono i worker honest; in alternativa per il primo avvio live usare PORT04-shape senza honest, oppure aggiungere i 3 worker honest come Task 8b. Da decidere in esecuzione. - Consistenza tipi:
sidusato come chiave ovunque (definizioni ↔ all_sleeve_equities ↔ ledger ↔ workers);weight_vectorfirma identica inweighting.pyePortfolio.weight_vector;_worker_equitygestisceMLWorkerWrapper. - Placeholder: nessun TBD nel codice; l'unico TODO è opzionale (arricchire inst_map via get_instruments) e non blocca.
Punto aperto per l'esecuzione: il default PORT06 contiene gli sleeve honest (DIP01/TR01/ROT02) che NON hanno ancora un worker live. Decidere a inizio esecuzione se (a) aggiungere Task 8b coi worker honest, oppure (b) far girare il primo live con un portafoglio senza honest (fade+pairs+shape) e tenere PORT06 completo solo in backtest finché i worker honest non esistono.