# Design — Cartella `portfolios/`: portafogli come oggetti di prima classe **Data:** 2026-05-29 **Stato:** approvato in brainstorming, pronto per il piano di implementazione **Branch:** `shape_patterns` (o branch dedicato `portfolios`) ## 1. Obiettivo e contesto Oggi le strategie del progetto vivono come *sleeve* indipendenti: ogni worker del paper trader (`StrategyWorker`, `PairsWorker`) gestisce un conto autonomo da €1000, con capitale e stato propri in `data/paper_trades/{worker_id}/`. I "portafogli" `PORT01-03` esistenti sono soltanto script di **report offline**: normalizzano le equity storiche dei singoli sleeve e ne calcolano metriche equipesate. Non esiste un livello che gestisca davvero un capitale condiviso, i pesi, il ribilanciamento e il PnL aggregato in tempo reale. Questo design introduce una cartella `portfolios/` in cui il **portafoglio è un oggetto di prima classe** che gestirà il trading e lo stato PnL. Un portafoglio possiede un capitale totale, lo alloca ai propri sleeve secondo uno schema di pesi, dimensiona le posizioni, ribilancia periodicamente e mantiene il ledger aggregato. La stessa definizione serve sia al backtest sia al live, garantendo coerenza fra ciò che si misura e ciò che si tradia. L'obiettivo strategico resta invariato: partire da €1000 e arrivare verso €50/giorno con un paniere diversificato delle famiglie validate (fade, honest, pairs, TSMOM, shape-ML). ## 2. Decisioni di brainstorming 1. **Modello di capitale: pool condiviso.** Il portafoglio possiede il capitale totale, lo alloca ai sleeve secondo i pesi, ridimensiona le posizioni e tiene lo stato/PnL aggregato. I worker diventano esecutori. 2. **Scope: backtest + live unificati.** Un'unica classe `Portfolio` come fonte di verità, capace sia di backtest/report storico sia di gestione live. 3. **Ribilanciamento periodico.** Il capitale viene riallocato ai pesi target a cadenza fissa (giornaliera di default, configurabile), coerente con tutte le metriche misurate finora. 4. **Schemi di peso supportati (tutti):** `equal` (default), `cap` (tetto per famiglia/cluster, es. pairs 33% — configurazione sobria raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol dentro), `manual`. 5. **Scope live v1: tutti gli sleeve** — fade, honest, pairs (2 gambe) e shape-ML (SH01 via worker con retraining periodico, sfruttando il `MLWorkerWrapper` esistente). 6. **Data layer Cerbero v2.** Il runner live adotta gli endpoint unificati v2: `get_historical` unificato, `get_instruments` (naming robusto, niente `INSTRUMENT_MAP` hardcoded), `get_ticker_batch` (fetch multi-gamba efficiente). Venue di trading = Deribit come ora. ### Analisi di accorpamento (a supporto delle decisioni) `scripts/analysis/sleeve_clustering.py` ha mostrato che: - i **cluster naturali** delle 17 sleeve non coincidono con le famiglie ma con asset/regime: BTC-reversion, ETH-reversion, trend (TR01+TSM01), shape (SH_BTC+SH_ETH), rotation (ROT02); - la **ridondanza è lieve** (correlazione massima 0.43 MR01_BTC↔DIP01_BTC, 0.37 TR01↔TSM01): nessuno sleeve è davvero fondibile, ognuno aggiunge diversificazione; - a equal-weight i **pairs pesano il 47% del rischio** → giustifica lo schema `cap`; - in OOS calmo equal-weight batte inverse-vol e risk-parity (i pairs ad alto rischio/ritorno corrono liberi), ma è un risultato di regime → il cap resta la scelta prudente. Il campo `cluster` di `SleeveSpec` codifica questi gruppi naturali per gli schemi `cap` e `cluster_rp`. ## 3. Architettura e layout Si rispecchia la struttura delle strategie (`src/strategies/` base + `scripts/strategies/` concrete): ``` src/portfolio/ __init__.py base.py # Portfolio (definizione + .backtest()), SleeveSpec, PortfolioResult sleeves.py # costruzione UNIFICATA delle equity-per-sleeve (backtest); # centralizza la logica oggi in combine_portfolio + report_families weighting.py # schemi pesi: equal, cap, inverse_vol, cluster_rp, manual ledger.py # PortfolioLedger: capitale, allocazioni, equity, PnL, peak/DD, persistenza runner.py # PortfolioRunner (live): pool capital, sizing, ribilancio, aggregazione scripts/portfolios/ PORT01_honest.py PORT02_fade.py PORT03_master.py PORT04_master_pairs.py PORT05_master_esteso.py PORT06_master_shape.py # definizioni concrete (lista SleeveSpec + schema pesi); run() = report backtest portfolios.yml # config LIVE: portafoglio attivo, capitale, schema pesi, cap, cadenza, leva ``` **Integrazione col codice esistente:** - Il backtest riusa i builder di equity-per-sleeve (`build_all_sleeves`, `pairs_sim`, `shape_daily_equity`), centralizzati in `src/portfolio/sleeves.py`; `combine_portfolio.py` e `report_families.py` diventano consumer sottili (niente duplicazione). - Il live riusa da `multi_runner`: il fetch candele, `build_workers`, `build_pairs_workers`, `MLWorkerWrapper`. `multi_runner` resta entrypoint legacy single-sleeve finché `PortfolioRunner` non lo sostituisce. - I vecchi `PORT01-03` di `scripts/strategies/` vengono migrati in `scripts/portfolios/` come definizioni della nuova classe. ## 4. Definizione del portafoglio (schema) ```python @dataclass class SleeveSpec: kind: str # "single" | "pairs" | "ml" name: str # "MR01_bollinger_fade" | "PR01_pairs_reversion" | "SH01_shape_ml" asset: str | None = None # single/ml a: str | None = None # pairs: gamba long b: str | None = None # pairs: gamba short tf: str = "1h" params: dict = field(default_factory=dict) cluster: str = "" # BTC-rev | ETH-rev | trend | shape | rotation @dataclass class Portfolio: code: str # "PORT06" label: str # "Master + shape" sleeves: list[SleeveSpec] weighting: str = "equal" # equal | cap | inverse_vol | cluster_rp | manual weights: dict | None = None # solo manual (sleeve-id -> peso) caps: dict | None = None # solo cap: chiave = FAMIGLIA (derivata da kind/name: # PAIRS/FADE/HONEST/SHAPE/TSM), es. {"PAIRS": 0.33}. # cluster_rp usa invece il campo `cluster` degli sleeve. total_capital: float = 1000.0 leverage: float = 3.0 # nota: 2x raccomandata per il live reale rebalance: str = "1D" vol_lookback: int = 90 # giorni per inverse_vol / cluster_rp def backtest(self, ...) -> PortfolioResult: ... def weight_vector(self, sleeve_returns) -> dict[str, float]: ... ``` Gli schemi di peso (in `weighting.py`) restituiscono un dict `sleeve-id -> peso` che somma a 1. `equal/cap/manual` sono statici; `inverse_vol/cluster_rp` si ricalcolano a ogni ribilancio sulla finestra trailing `vol_lookback`, identicamente in backtest e live. ## 5. Faccia backtest `Portfolio.backtest()` riusa la macchina che ha prodotto tutte le metriche viste finora, centralizzata in `src/portfolio/sleeves.py`: ``` build_sleeve_equity(spec) -> pd.Series # equity daily normalizzata su IDX comune kind="single" -> fade/honest daily equity builders kind="pairs" -> pairs_sim -> daily kind="ml" -> shape_daily_equity ``` Poi: `weight_vector()` → pesi → `port_returns()` con ribilancio giornaliero → `metrics()` FULL/OOS + `yearly_returns()`. Restituisce un `PortfolioResult` con ret/CAGR/DD/Sharpe (FULL e OOS), tabella per-anno e contributo al rischio per sleeve e per cluster. Lo `run()` di ogni `scripts/portfolios/PORTxx.py` stampa questo report. ## 6. Faccia live (`PortfolioRunner`) Loop a poll: 1. **Data layer v2.** All'avvio `get_instruments` risolve i nomi reali di ogni asset/coppia (fallback a una mappa statica se l'endpoint non risponde). Per tick: `get_historical` unificato per le candele + `get_ticker_batch` per i prezzi correnti di tutte le gambe in un'unica chiamata. 2. **Costruzione sleeve→worker.** Riusa `build_workers` / `build_pairs_workers` / `MLWorkerWrapper` (SH01). I worker sono esecutori, non possiedono più €1000 fissi. 3. **Capitale pool + sizing.** Il `PortfolioLedger` tiene `total_capital`. A ogni worker viene assegnato `alloc_i = peso_i × total_capital`; il worker dimensiona il notional come `alloc_i × position_size × leverage` (si riusa il campo `capital` del worker come base di allocazione). 4. **Ribilancio (cadenza `rebalance`, default giornaliera).** `total_capital = Σ equity_sleeve` (capitale + PnL realizzato); ricalcolo dei pesi (vol-based sulla finestra trailing o statici); riallineo `alloc_i`. 5. **Aggregazione.** Dopo ogni tick il ledger aggiorna equity totale, peak, max_dd, PnL aggregato e per-sleeve/cluster. ### Approssimazione dichiarata (limite noto) Il ribilancio cambia la base di sizing delle posizioni **future**; le posizioni già aperte restano sul notional con cui sono nate (nessun travaso forzato a metà trade). Per il paper trading questo è fedele al backtest daily-rebalanced entro lo scarto dovuto al turnover infragiornaliero. È un compromesso accettato per non introdurre la contabilità a ledger unico (approccio C scartato in brainstorming), rimandata a quando si passerà a capitale reale su un singolo conto-margine. ## 7. Persistenza e stato PnL Stato del portafoglio separato dai singoli worker, in `data/portfolios/{code}/`: ``` data/portfolios/PORT06/ status.json # resume: total_capital, equity, peak, max_dd, pesi correnti, # alloc+capitale+PnL per sleeve, ultimo ribilancio, ts equity.jsonl # append-only: una riga per tick/giorno (ts, equity, dd, pnl_day) -> curva live events.jsonl # append-only: ribilanci (pesi prima/dopo), milestone, errori ``` - I worker continuano a scrivere il proprio `trades.jsonl`/`status.json` in `data/paper_trades/{worker_id}/` (storico per-sleeve intatto). Il portafoglio aggrega sopra, non duplica i trade. - **Resume:** al restart il runner ricarica lo `status.json` del portafoglio e gli stati dei worker → riprende capitale, pesi e posizioni senza perdere storico. - **Indicatori target:** il ledger espone `pnl_total`, `pnl_today`, `€/day` medio e DD corrente. - **Notifiche Telegram:** riepilogo a livello portafoglio (equity, PnL giorno, DD, ribilanci) oltre alle notifiche per-trade dei worker. ## 8. Portafogli forniti e default | Codice | Label | Sleeve | Pesi | |--------|-------|--------|------| | PORT01 | Honest | DIP01·TR01·ROT02 | equal | | PORT02 | Fade master | MR01/02/07 × BTC/ETH (6) | equal | | PORT03 | Master | fade+honest (9) | equal / manual 50-50 | | PORT04 | Master + pairs | 9 + 5 pairs | equal · cap pairs 0.33 | | PORT05 | Master esteso | 9 + pairs + TSM01 | equal · cap pairs | | **PORT06** | **Master + shape** *(default)* | 9 + pairs + TSM01 + SH01 (BTC/ETH) | **cap pairs 0.33** | **Default raccomandato:** PORT06 con `weighting="cap"` (pairs ~33%), `leverage=2` (sobrio), `rebalance="1D"`. È la combinazione col miglior profilo OOS dell'analisi (Sharpe più alto, DD più basso) e contiene tutte le famiglie validate. `portfolios.yml` seleziona il portafoglio attivo e i suoi override. ## 9. Test - **Unit** — `weighting.py` (somma pesi = 1, cap rispettato e ridistribuito, inverse-vol/cluster corretti); `ledger.py` (capitale/PnL/DD, resume da status.json). - **Parità backtest↔report** — `Portfolio.backtest()` di PORT03/04/05/06 riproduce *esattamente* i numeri di `report_families.py` (regressione, stessa fonte). - **Parità live↔backtest** — replay del `PortfolioRunner` su dati storici con ribilancio giornaliero ≈ `Portfolio.backtest()` entro tolleranza (lo scarto è il turnover infragiornaliero dichiarato), sullo stesso schema della validazione dei pairs. - **Smoke live** — un tick reale end-to-end via Cerbero v2 (get_instruments + get_historical + ticker_batch), nessun ordine reale, verifica ledger/persistenza/resume. ## 10. Fuori scope (note per il futuro) - **Ledger unico / conto-margine reale** (approccio C): rinviato al passaggio a capitale reale. - **Hyperliquid come venue per gli alt** dei pairs (perp lineari nativi, evita i trap di naming Deribit) — opzione abilitata dal data layer v2, non in v1. - **Validazione pairs live via `get_cointegration_pairs`** e feature da macro/sentiment (funding, liquidation, OI) per strategie future. - **`run_backtest` server-side** di Cerbero come check incrociato.