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>
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
# Fase 2-B — Worker live honest/TSM01 (dedicati) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development o executing-plans. Steps con checkbox `- [ ]`.
|
||||
|
||||
**Goal:** Costruire i worker live mancanti perché PORT06 giri live al completo (oltre a fade+pairs+shape già pronti): DIP01, TR01 (basket), ROT02 (rotation), TSM01 (tsmom rotation), e integrarli nel `PortfolioRunner`.
|
||||
|
||||
**Architecture:** Worker DEDICATI per ogni strategia (scelta utente). DIP01 è single-asset → Strategy subclass + `StrategyWorker` esistente. TR01/ROT02/TSM01 sono multi-asset/rotation → tre classi worker nuove in `src/live/` con stato per-asset persistente, ciascuna fedele alla rispettiva funzione di backtest in `scripts/analysis/{honest_improve2,tsmom_research}.py`. Integrazione in `src/portfolio/runner.py::build_worker_for` + tick.
|
||||
|
||||
**Tech Stack:** Python 3.11, pandas/numpy, pytest. Riusa CerberoClient v2 (multi-asset fetch), PortfolioLedger, e le funzioni di riferimento honest/tsm.
|
||||
|
||||
**Branch:** `portfolio_phase2`. **Spec madre:** `docs/superpowers/specs/2026-05-29-portfolios-design.md` (§ scope live, fase 2).
|
||||
|
||||
**Riferimenti di logica (NON modificare, sono la verità del backtest):**
|
||||
- DIP01 → `honest_improve2.dip_market_gated` (z-score dip, gate BTC>SMA, TP=SMA/SL=ATR/max_bars, intrabar).
|
||||
- TR01 → `honest_improve2._tr_basket_daily` (per asset 4h: EMA20>EMA100 long/flat; basket equal-weight).
|
||||
- ROT02 → `honest_improve2._rot_daily_equity` (panel 1d, mom 60g, top-3 se mom>0 e BTC>SMA100, gross 0.45 split, ribilancio giornaliero).
|
||||
- TSM01 → `tsmom_research.tsmom_sim` (panel 1d, Σ sign(P/P[-h]) h∈{63,126,252} ≥ thr=1.0, gate BTC>SMA100, gross 0.30 split).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsabilità |
|
||||
|------|----------------|
|
||||
| `scripts/strategies/DIP01_dip_buy.py` | Strategy `Dip01DipBuy` (single-asset; metadata tp/sl/max_bars + gate) |
|
||||
| `src/live/basket_trend_worker.py` | `BasketTrendWorker` (TR01): N asset 4h, EMA cross, long/flat per asset |
|
||||
| `src/live/rotation_worker.py` | `RotationWorker` (ROT02): panel 1d, dual-momentum top-k, gross split |
|
||||
| `src/live/tsmom_worker.py` | `TsmomWorker` (TSM01): panel 1d, consenso segni multi-orizzonte |
|
||||
| `src/live/strategy_loader.py` | **mod**: aggiungi `DIP01_dip_buy` a MODULE_MAP |
|
||||
| `src/portfolio/runner.py` | **mod**: `build_worker_for` gestisce kind "basket"/"rotation"/"tsmom"; tick multi-asset |
|
||||
| `src/portfolio/base.py` (`_defs.py`) | **mod**: SleeveSpec degli honest/tsm con `kind` e `universe` corretti |
|
||||
| `tests/portfolio/test_honest_workers.py` | unit per ciascun worker + replay==backtest su finestra |
|
||||
|
||||
**Universi:** TR01 = [BNB,BTC,DOGE,SOL,XRP] (4h); ROT02/TSM01 = `available_assets()` (1d). I worker multi-asset ricevono il dict {asset: df} dal runner.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: DIP01 come Strategy single-asset
|
||||
|
||||
**Files:** Create `scripts/strategies/DIP01_dip_buy.py`; Modify `src/live/strategy_loader.py`; Test `tests/portfolio/test_dip01.py`.
|
||||
|
||||
- [ ] **Step 1: Test (fallisce)** — `tests/portfolio/test_dip01.py`:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
from scripts.strategies.DIP01_dip_buy import Dip01DipBuy
|
||||
|
||||
|
||||
def test_dip01_generates_long_signals_with_exits():
|
||||
df = load_data("BTC", "1h").iloc[-5000:].reset_index(drop=True)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
sigs = Dip01DipBuy().generate_signals(df, ts, asset="BTC", tf="1h")
|
||||
assert len(sigs) > 0
|
||||
s = sigs[0]
|
||||
assert s.direction == 1 # dip-buy è solo long
|
||||
assert {"tp", "sl", "max_bars"} <= set(s.metadata)
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_dip01.py -v` → FAIL (ModuleNotFoundError).
|
||||
|
||||
- [ ] **Step 3: Implementa `scripts/strategies/DIP01_dip_buy.py`.** Replica ESATTA della logica di `dip_market_gated` (default `market_n=0` = senza gate, come lo sleeve DIP01_BTC del portafoglio: vedi combine_portfolio che usa `market_n=0`). Genera Signal long quando `z[i] <= -z_in and z[i-1] > -z_in`, con metadata `tp=SMA[i]`, `sl=c[i]-sl_atr*atr[i]`, `max_bars`. fee_rt=0.001, leverage 3, position 0.15.
|
||||
|
||||
```python
|
||||
"""DIP01 — Dip-buy mean-reversion single-asset (z-score sotto-banda). Honest family.
|
||||
|
||||
Replica live della logica validata in scripts/analysis/honest_improve2.dip_market_gated
|
||||
(con market_n=0, come lo sleeve DIP01_BTC del portafoglio): compra quando lo z-score del
|
||||
prezzo rispetto a SMA(n) incrocia sotto -z_in; esce a TP=SMA, SL=close-sl_atr*ATR, o max_bars.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.strategies.base import Strategy, Signal # noqa: E402
|
||||
|
||||
|
||||
def _atr(df, n=14):
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(n).mean().values
|
||||
|
||||
|
||||
class Dip01DipBuy(Strategy):
|
||||
name = "DIP01_dip_buy"
|
||||
description = "Dip-buy mean-reversion single-asset (z-score), exit TP=SMA/SL=ATR/max_bars"
|
||||
default_assets = ["BTC"]
|
||||
default_timeframes = ["1h"]
|
||||
fee_rt = 0.001
|
||||
leverage = 3.0
|
||||
position_size = 0.15
|
||||
|
||||
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
|
||||
n: int = 50, z_in: float = 2.5, sl_atr: float = 2.5,
|
||||
max_bars: int = 24, **params) -> list[Signal]:
|
||||
c = df["close"].values
|
||||
ma = pd.Series(c).rolling(n).mean().values
|
||||
sd = pd.Series(c).rolling(n).std().values
|
||||
a = _atr(df, 14)
|
||||
z = (c - ma) / np.where(sd == 0, np.nan, sd)
|
||||
out: list[Signal] = []
|
||||
for i in range(n + 14, len(c)):
|
||||
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
|
||||
continue
|
||||
if z[i] <= -z_in and z[i - 1] > -z_in:
|
||||
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
|
||||
metadata={"tp": float(ma[i]),
|
||||
"sl": float(c[i] - sl_atr * a[i]),
|
||||
"max_bars": int(max_bars)}))
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Registra nel loader.** In `src/live/strategy_loader.py` MODULE_MAP aggiungi:
|
||||
```python
|
||||
"DIP01_dip_buy": ("DIP01_dip_buy", "Dip01DipBuy"),
|
||||
```
|
||||
|
||||
- [ ] **Step 5:** `uv run pytest tests/portfolio/test_dip01.py -v` → 1 passed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add scripts/strategies/DIP01_dip_buy.py src/live/strategy_loader.py tests/portfolio/test_dip01.py
|
||||
git commit -m "feat(live): DIP01 dip-buy come Strategy single-asset (worker via StrategyWorker)"
|
||||
```
|
||||
|
||||
**Nota:** DIP01 nel runner usa lo StrategyWorker esistente (kind="single", name="DIP01"). Aggiorna `_STRAT_MODULE` in `runner.py` con `"DIP01": "DIP01_dip_buy"` e in `_defs.py` lo SleeveSpec DIP01_BTC resta kind="single". Il backtest dello sleeve DIP01_BTC continua a venire da `build_everything` (parità invariata).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `BasketTrendWorker` (TR01)
|
||||
|
||||
**Files:** Create `src/live/basket_trend_worker.py`; Test `tests/portfolio/test_basket_worker.py`.
|
||||
|
||||
- [ ] **Step 1: Test (fallisce)** — verifica che, dato un dict {asset: df 4h}, il worker calcoli posizione long/flat per asset secondo EMA20>EMA100 e aggiorni il capitale equal-weight:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.live.basket_trend_worker import BasketTrendWorker
|
||||
|
||||
|
||||
def _ramp_df(n=300, slope=1.0):
|
||||
c = np.linspace(100, 100 + slope * n, n)
|
||||
ts = (pd.date_range("2024-01-01", periods=n, freq="4h", tz="UTC").astype("int64") // 10**6)
|
||||
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||
|
||||
|
||||
def test_basket_goes_long_in_uptrend(tmp_path):
|
||||
w = BasketTrendWorker(universe=["AAA", "BBB"], tf="4h", capital=1000.0, data_dir=tmp_path)
|
||||
data = {"AAA": _ramp_df(slope=1.0), "BBB": _ramp_df(slope=1.0)}
|
||||
w.tick(data)
|
||||
assert w.positions["AAA"] == 1.0 and w.positions["BBB"] == 1.0 # EMA20>EMA100 in salita
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implementa `src/live/basket_trend_worker.py`.** Stato: capitale totale + dict `positions` (asset→0/1) + persistenza. `tick(data: dict[str,df])`: per ogni asset calcola EMA20/EMA100 sull'ultima barra; target = 1.0 se ef>es else 0.0; applica fee `FEE_RT/2*LEV` sul turnover |Δpos|; aggiorna capitale equal-weight col rendimento di barra di ogni asset attivo (`POS*LEV*ret*pos/len(universe)`... mantieni la convenzione di `_tr_basket_daily`: ogni asset è uno sleeve normalizzato, equal-weight → applica `mean` dei rendimenti per-asset). Persisti `status.json` (capitale, positions, last_bar_ts per asset) e logga `trades.jsonl`. fee_rt=0.001, leverage 3, position 0.15.
|
||||
|
||||
```python
|
||||
"""BasketTrendWorker (TR01): EMA20>EMA100 long/flat su un paniere, equal-weight.
|
||||
Replica live di honest_improve2._tr_basket_daily."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
|
||||
|
||||
|
||||
def _ema(x, n):
|
||||
return pd.Series(x).ewm(span=n, adjust=False).mean().values
|
||||
|
||||
|
||||
class BasketTrendWorker:
|
||||
def __init__(self, universe, tf="4h", capital=1000.0, position_size=POS,
|
||||
leverage=LEV, fee_rt=FEE_RT, name="TR01_basket",
|
||||
data_dir=Path("data/portfolio_paper")):
|
||||
self.universe = list(universe)
|
||||
self.tf = tf
|
||||
self.initial_capital = capital
|
||||
self.capital = capital
|
||||
self.position_size = position_size
|
||||
self.leverage = leverage
|
||||
self.fee_rt = fee_rt
|
||||
self.worker_id = f"{name}__{'-'.join(self.universe)}__{tf}"
|
||||
self.work_dir = Path(data_dir) / self.worker_id
|
||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.status_path = self.work_dir / "status.json"
|
||||
self.trades_path = self.work_dir / "trades.jsonl"
|
||||
self.positions = {a: 0.0 for a in self.universe}
|
||||
self.last_bar_ts = {a: 0 for a in self.universe}
|
||||
self.in_position = False # per il ribilancio del runner (skip se True)
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if self.status_path.exists():
|
||||
s = json.loads(self.status_path.read_text())
|
||||
self.capital = s.get("capital", self.capital)
|
||||
self.positions = {**self.positions, **s.get("positions", {})}
|
||||
self.last_bar_ts = {**self.last_bar_ts, **s.get("last_bar_ts", {})}
|
||||
self.in_position = any(v > 0 for v in self.positions.values())
|
||||
|
||||
def _save(self):
|
||||
self.status_path.write_text(json.dumps({
|
||||
"capital": round(self.capital, 2), "positions": self.positions,
|
||||
"last_bar_ts": self.last_bar_ts,
|
||||
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
|
||||
|
||||
def tick(self, data: dict):
|
||||
rets = []
|
||||
for a in self.universe:
|
||||
df = data.get(a)
|
||||
if df is None or len(df) < 110:
|
||||
continue
|
||||
c = df["close"].values
|
||||
ef, es = _ema(c, 20)[-1], _ema(c, 100)[-1]
|
||||
target = 1.0 if ef > es else 0.0
|
||||
bar_ts = int(df["timestamp"].iloc[-1])
|
||||
prev = self.positions[a]
|
||||
# rendimento di barra realizzato sulla posizione precedente (chiusa->aperta barra)
|
||||
if self.last_bar_ts[a] and bar_ts > self.last_bar_ts[a] and prev > 0:
|
||||
r = (c[-1] - c[-2]) / c[-2]
|
||||
rets.append(self.position_size * self.leverage * r * prev)
|
||||
if target != prev:
|
||||
self.capital -= self.capital * self.position_size * (self.fee_rt / 2) * abs(target - prev) / len(self.universe)
|
||||
self._log(a, prev, target, float(c[-1]))
|
||||
self.positions[a] = target
|
||||
self.last_bar_ts[a] = bar_ts
|
||||
if rets:
|
||||
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
|
||||
self.in_position = any(v > 0 for v in self.positions.values())
|
||||
self._save()
|
||||
|
||||
def _log(self, asset, frm, to, price):
|
||||
with open(self.trades_path, "a") as f:
|
||||
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"asset": asset, "from": frm, "to": to,
|
||||
"price": round(price, 6), "capital": round(self.capital, 2)}) + "\n")
|
||||
|
||||
@property
|
||||
def status_summary(self):
|
||||
longs = [a for a, v in self.positions.items() if v > 0]
|
||||
return f"{self.worker_id}: cap={self.capital:.0f} long={longs}"
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → 1 passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add src/live/basket_trend_worker.py tests/portfolio/test_basket_worker.py
|
||||
git commit -m "feat(live): BasketTrendWorker (TR01) EMA-cross long/flat multi-asset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `RotationWorker` (ROT02)
|
||||
|
||||
**Files:** Create `src/live/rotation_worker.py`; Test `tests/portfolio/test_rotation_worker.py`.
|
||||
|
||||
- [ ] **Step 1: Test (fallisce)** — dato {asset: df 1d}, sceglie i top-k per momentum 60g con gate BTC>SMA100 e imposta i pesi gross/k:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.live.rotation_worker import RotationWorker
|
||||
|
||||
|
||||
def _df(n=200, slope=1.0):
|
||||
c = np.linspace(100, 100 + slope * n, n)
|
||||
ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6)
|
||||
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
|
||||
|
||||
|
||||
def test_rotation_picks_top_momentum_when_risk_on(tmp_path):
|
||||
w = RotationWorker(universe=["BTC", "AAA", "BBB"], top_k=2, gross=0.45, data_dir=tmp_path)
|
||||
data = {"BTC": _df(slope=1.0), "AAA": _df(slope=3.0), "BBB": _df(slope=0.1)}
|
||||
w.tick(data)
|
||||
# BTC in uptrend -> risk_on; top-2 momentum = AAA e BTC; pesi gross/2
|
||||
assert w.weights["AAA"] > 0 and abs(sum(w.weights.values()) - 0.45) < 1e-9
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_rotation_worker.py -v` → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implementa `src/live/rotation_worker.py`.** Replica di `_rot_daily_equity`: panel di close 1d allineato; `risk_on = BTC[-1] > SMA100(BTC)[-1]`; `mom = P[-1]/P[-61]-1`; `chosen = [top_k per mom con mom>0] se risk_on else []`; pesi `gross/len(chosen)`; turnover fee `FEE_RT/2 * Σ|Δw|`; capitale aggiornato col rendimento di portafoglio del giorno successivo (live: al tick si realizza il rendimento dell'ultima barra sui pesi correnti, poi si ricalcolano i pesi). Persisti capitale+weights+last_ts. `in_position = bool(weights)`.
|
||||
|
||||
(Implementazione analoga a BasketTrendWorker: stato persistente, `tick(data)` allinea i panel per timestamp comune, calcola momentum/gate, applica fee sul turnover e rendimento di barra. Mantieni `top_k=3, gross=0.45` come default — i valori dello sleeve ROT02_rot del portafoglio.)
|
||||
|
||||
- [ ] **Step 4:** test → 1 passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add src/live/rotation_worker.py tests/portfolio/test_rotation_worker.py
|
||||
git commit -m "feat(live): RotationWorker (ROT02) dual-momentum top-k risk-gated"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `TsmomWorker` (TSM01)
|
||||
|
||||
**Files:** Create `src/live/tsmom_worker.py`; Test `tests/portfolio/test_tsmom_worker.py`.
|
||||
|
||||
- [ ] **Step 1: Test (fallisce)** — consenso segni multi-orizzonte: sceglie gli asset con `Σ sign(P/P[-h]) ≥ thr` (h∈{63,126,252}) sotto gate, pesi gross/k.
|
||||
|
||||
- [ ] **Step 2-3: Implementa `src/live/tsmom_worker.py`** replicando `tsmom_sim`: `score[j] = mean_h sign(P[-1,j]/P[-1-h,j]-1)`; `chosen = [j: score>=thr] se risk_on`; pesi `gross/len(chosen)` con `gross=0.30`. Stessa struttura di RotationWorker (panel 1d, fee turnover, rendimento di barra, persistenza). Default `horizons=(63,126,252), thr=1.0, regime_n=100, gross=0.30`.
|
||||
|
||||
- [ ] **Step 4:** test → passed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add src/live/tsmom_worker.py tests/portfolio/test_tsmom_worker.py
|
||||
git commit -m "feat(live): TsmomWorker (TSM01) consenso TSMOM multi-orizzonte risk-gated"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Integrazione nel PortfolioRunner
|
||||
|
||||
**Files:** Modify `src/portfolio/runner.py`, `scripts/portfolios/_defs.py`, `src/portfolio/base.py`; Test `tests/portfolio/test_runner_honest.py`.
|
||||
|
||||
- [ ] **Step 1:** In `_defs.py`, marca gli SleeveSpec multi-asset col `kind` giusto e l'universo:
|
||||
- DIP01 → `kind="single", name="DIP01"` (resta StrategyWorker via _STRAT_MODULE["DIP01"]="DIP01_dip_buy").
|
||||
- TR01 → `kind="basket"`, aggiungi campo universo (riusa `params={"universe": ["BNB","BTC","DOGE","SOL","XRP"], "tf": "4h"}`).
|
||||
- ROT02 → `kind="rotation"`, `params={"top_k":3, "gross":0.45, "tf":"1d"}`.
|
||||
- TSM01 → `kind="tsmom"`, `params={"horizons":[63,126,252], "thr":1.0, "gross":0.30, "tf":"1d"}`.
|
||||
(Aggiungi `universe`/campi a SleeveSpec se serve, default None.)
|
||||
|
||||
- [ ] **Step 2:** In `runner.py::build_worker_for` aggiungi i rami `kind in ("basket","rotation","tsmom")` che costruiscono i rispettivi worker con `capital=alloc_capital` e `data_dir=DATA_DIR`. Aggiorna `_STRAT_MODULE` con `"DIP01": "DIP01_dip_buy"`. Rimuovi DIP01/TR01/ROT02/TSM01 dalla lista "saltati": ora sono supportati.
|
||||
|
||||
- [ ] **Step 3:** In `runner.run()` il tick deve passare ai worker multi-asset un dict {asset: df} (fetch di tutti gli asset dell'universo). Estendi la raccolta `keys` e il dispatch del tick: per kind basket/rotation/tsmom costruisci `data = {a: cache[(a, tf)] for a in universe}` e chiama `w.tick(data)`. Per `_worker_equity` i nuovi worker espongono `.capital` (già ok). Per il ribilancio, espongono `.in_position` (skip se True).
|
||||
|
||||
- [ ] **Step 4: Test** `tests/portfolio/test_runner_honest.py`: `build_worker_for` ritorna il tipo giusto per ogni kind con capitale = alloc; e `run()` con PORT06 non lascia più sleeve "saltati" (mocka il fetch o testa solo build).
|
||||
|
||||
- [ ] **Step 5:** `uv run pytest tests/portfolio/ -m "not network" -v` → tutti verdi.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add src/portfolio/runner.py scripts/portfolios/_defs.py src/portfolio/base.py tests/portfolio/test_runner_honest.py
|
||||
git commit -m "feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Validazione replay==backtest per i worker multi-asset
|
||||
|
||||
**Files:** Modify `scripts/analysis/validate_portfolio_runner.py` (o nuovo `validate_honest_workers.py`).
|
||||
|
||||
- [ ] **Step 1:** Per ogni worker multi-asset, replay bar-by-bar su dati storici (load_data) e confronto dell'equity finale con la funzione di riferimento (`_tr_basket_daily`, `_rot_daily_equity`, `tsmom_sim`) entro tolleranza. ROT02/TSM01 sono daily → replay veloce (poche migliaia di barre). TR01 4h → medio. Atteso: match stretto (differenze solo da bar-timing/cadenza). DIP01 ha il gap intrabar noto come le fade (documenta, non assert esatto).
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
```bash
|
||||
git add scripts/analysis/validate_honest_workers.py
|
||||
git commit -m "test(portfolio): replay worker honest/TSM01 == backtest di riferimento"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Copertura:** i 4 worker (DIP01 single via Strategy; TR01/ROT02/TSM01 dedicati) + integrazione runner + validazione → PORT06 gira live completo (niente più sleeve saltati).
|
||||
- **Parità backtest:** invariata (gli sleeve del backtest vengono ancora da `build_everything`; i worker sono il path LIVE). La validazione replay==backtest (Task 6) certifica i worker live.
|
||||
- **Gap noto:** DIP01, come le fade, ha exit intrabar nel backtest ma close-based nel live → gap strutturale documentato (non un bug). TR01/ROT02/TSM01 non hanno TP/SL intrabar (entry/exit a chiusura barra/giorno) → replay atteso stretto.
|
||||
- **Tipi:** i nuovi worker espongono `.capital` e `.in_position` (richiesti da `_worker_equity`/`rebalance_allocations`); `tick(data: dict)` per i multi-asset vs `tick(df)`/`tick(dfa,dfb)` esistenti → il runner dispatcha per `kind`.
|
||||
- **Rischio:** la convenzione di capitale/rendimento dei worker multi-asset deve combaciare con le funzioni di riferimento; la validazione Task 6 è il gate che lo verifica — se diverge, allineare la formula (non la reference).
|
||||
|
||||
> **Punto aperto:** verificare la disponibilità su Cerbero v2 dei timeframe 4h/1d per tutti gli asset dell'universo (TR01 usa 4h; ROT02/TSM01 usano 1d, oggi resample da 1h in get_df). Il runner live dovrà resamplare 1h→4h/1d dal feed v2 o fetchare nativamente — da decidere in Task 5/Step 3.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user