Files
PythagorasGoal/Old/docs/superpowers/plans/2026-05-29-portfolios.md
T
Adriano Dal Pastro 14522262e6 chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita
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>
2026-06-19 15:20:59 +00:00

1228 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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; marcato `network`)
- [ ] **Step 1: Crea il package di test**
```bash
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:
```python
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`:
```python
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:
```toml
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
```
- [ ] **Step 6: Commit**
```bash
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**
```bash
mkdir -p src/portfolio && touch src/portfolio/__init__.py
```
- [ ] **Step 2: Scrivi i test (falliscono)**
`tests/portfolio/test_weighting.py`:
```python
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`**
```python
"""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**
```bash
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`:
```python
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`**
```python
"""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**
```bash
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`:
```python
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`**
```python
"""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**
```bash
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`:
```python
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**
```bash
touch scripts/portfolios/__init__.py
```
`scripts/portfolios/_defs.py`:
```python
"""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`:
```python
"""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**
```bash
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`:
```python
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`**
```python
"""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**
```bash
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` (aggiunge `Portfolio.from_active_config`)
- Test: `tests/portfolio/test_config.py`
- [ ] **Step 1: Crea `portfolios.yml`**
```yaml
# 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`:
```python
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`:
```python
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**
```bash
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`:
```python
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`**
```python
"""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**
```bash
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`:
```python
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`:
```python
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`:
```python
"""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**
```bash
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:
```bash
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**
```bash
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_allocations` e documentato (Task 9 docstring + Task 10).
- **Honest DIP01/TR01/ROT02 nel live:** `build_worker_for` solleva 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:** `sid` usato come chiave ovunque (definizioni ↔ all_sleeve_equities ↔ ledger ↔ workers); `weight_vector` firma identica in `weighting.py` e `Portfolio.weight_vector`; `_worker_equity` gestisce `MLWorkerWrapper`.
- **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.