14522262e6
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>
1228 lines
45 KiB
Markdown
1228 lines
45 KiB
Markdown
# 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.
|