From 6097205694824d9dc37615e17354ea8271f60bc5 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 7 Jun 2026 10:44:15 +0000 Subject: [PATCH] feat(pairs): position_size per-famiglia, PAIRS 0.5->0.20 (gate PORT06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punto 8 roadmap sweep. La famiglia pairs (SENZA stop, validata a pos 0.15 lev 3 = esposizione 0.45) girava col position_size globale 0.5 a leva 2 = esposizione 1.0, ~2.2x il validato (ETH/BTC DD grezzo 78% a quella taglia; live: ADA/ETH -4.26% sleeve in un trade il 2026-06-05). Gate pairspos_port06_impact.py (pairs_sim alla leva live 2x, parita' canonica 1.00000 5/5, PORT06 pesi cap): griglia pos {0.5, 0.25, 0.20, 0.15}. Il gate meccanico boccia ogni riduzione (costa OOS Sharpe — il compounding in-sample a 0.5 e' fantasia: +2.6e9% ETH/BTC), ma 0.20 e' il ginocchio del trade-off: OOS DD 3.40->1.26% (meglio anche di 0.15), famiglia DD 14->5.8%, costo OOS Sharpe 9.05->8.43. Decisione assicurativa (utente), come il precedente cap SHAPE. - runner.pos_for_spec(sid, global, family_overrides): override per-famiglia (chiave weighting.family_of), plumbato in build_worker_for. - portfolios.yml: position_size_family {PAIRS: 0.20} (globale 0.5 invariato). - Test: pos_for_spec mapping + PairsWorker.position_size (74/74 verdi). Nota transizione: la posizione LTC/ETH aperta verra' chiusa col pos nuovo (one-time). Diario docs/diary/2026-06-07-pairspos-gate.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 + docs/diary/2026-06-07-pairspos-gate.md | 46 +++++++ portfolios.yml | 6 + scripts/analysis/pairspos_port06_impact.py | 147 +++++++++++++++++++++ src/portfolio/runner.py | 17 ++- tests/portfolio/test_runner_build.py | 22 ++- 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 docs/diary/2026-06-07-pairspos-gate.md create mode 100644 scripts/analysis/pairspos_port06_impact.py diff --git a/CLAUDE.md b/CLAUDE.md index 77ab89d..af8368e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -373,6 +373,12 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale `PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET). Diario `docs/diary/2026-06-07-sweep-fixes.md`. - **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`. +- **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family` + (chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione + 0.40 ≈ il validato 0.45): la famiglia è senza stop e col globale 0.5×lev2 girava a ~2.2x il + validato (ETH/BTC DD grezzo 78% a quella taglia). PORT06 OOS DD 3.40→1.26% al costo di OOS + Sharpe 9.05→8.43 — assicurazione come il cap SHAPE. Gate `pairspos_port06_impact.py`, + diario `docs/diary/2026-06-07-pairspos-gate.md`. - **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero. ## Multi-Strategy Paper Trader diff --git a/docs/diary/2026-06-07-pairspos-gate.md b/docs/diary/2026-06-07-pairspos-gate.md new file mode 100644 index 0000000..88de299 --- /dev/null +++ b/docs/diary/2026-06-07-pairspos-gate.md @@ -0,0 +1,46 @@ +# 2026-06-07 — Gate position_size PAIRS: 0.5 → 0.20 (assicurazione, come il cap SHAPE) + +Punto 8 della roadmap improvement-sweep: i pairs giravano col `position_size` +GLOBALE 0.5 a leva 2 (esposizione 1.0 della fetta) mentre TUTTA la validazione PR01 +è a pos 0.15 lev 3 (esposizione 0.45) — ~2.2x il validato su una famiglia **senza +stop** (exit solo |z|≤z_exit o max_bars). Scelta deliberata (commento yml) ma mai +gateata. Evidenza live: ADA/ETH 2026-06-05 net −8.52% = −4.26% di sleeve in un trade. + +## Metodo + +`scripts/analysis/pairspos_port06_impact.py`: le 5 equity pairs ricostruite con +`pairs_sim` alla **leva live (2x)** su griglia pos {0.50, 0.25, 0.20, 0.15}, +innestate nel PORT06 canonico (pesi cap PAIRS 0.33 + SHAPE 0.0588). Parità +verificata: `pairs_sim(0.15, lev3)` == equity canoniche (corr 1.00000, 5/5). + +## Risultati + +| pos | esp. | FULL Sh / DD | CAGR | OOS Sh / DD | fam DD | worst pair DD grezzo | +|---|---|---|---|---|---|---| +| 0.50 (live) | 1.00 | 6.69 / 4.77% | 130% | 9.05 / 3.40% | 14.0% | ETH/BTC 78% | +| 0.25 | 0.50 | 6.50 / 4.04% | 67% | 8.69 / 1.54% | 7.2% | 51% | +| **0.20** | **0.40** | 6.33 / 3.90% | 57% | 8.43 / **1.26%** | 5.8% | 44% | +| 0.15 | 0.30 | 6.03 / 3.78% | 47% | 7.99 / 1.33% | 4.3% | 35% | + +Letture: +- Il gate meccanico (OOS Sharpe non peggiora) **boccia ogni riduzione**: ovvio — + ridurre l'esposizione di una famiglia che in-sample non è mai esplosa costa sempre + Sharpe. Ma il +130% CAGR / +2.6e9% compounded di ETH/BTC a pos 0.5 è fantasia da + compounding: dice solo che il tail event non è ancora successo in-sample. +- **0.20 è il ginocchio**: OOS DD 1.26% batte anche 0.15 (1.33% — oltre il ginocchio + si taglia ritorno senza guadagnare DD); esposizione 0.40 ≈ il validato 0.45. +- Decisione di tipo assicurativo, identica al precedente cap SHAPE (adottato pagando + −0.24 OOS Sharpe). + +## Decisione (utente): pos PAIRS = 0.20 + +Implementazione: +- `runner.pos_for_spec(sid, global_ps, family_overrides)` — override per-famiglia + (chiave = `weighting.family_of`), plumbato in `build_worker_for`. +- `portfolios.yml`: `position_size_family: {PAIRS: 0.20}` (globale 0.5 invariato + per fade/dip/shape). +- Test: `test_pos_for_spec_family_override`, `test_build_pairs_worker_position_size`. + +Nota di transizione: la posizione LTC/ETH aperta al deploy (entrata a sizing 0.5) +verrà chiusa col pos nuovo 0.20 → il pnl % sleeve di quel singolo trade è bookato +alla taglia nuova (one-time, conservativo). diff --git a/portfolios.yml b/portfolios.yml index 0860228..08449cf 100644 --- a/portfolios.yml +++ b/portfolios.yml @@ -15,6 +15,12 @@ overrides: # 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego # dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato). position_size: 0.5 + # Override per-famiglia (chiave = weighting.family_of). PAIRS 0.20 (2026-06-07): + # famiglia SENZA stop, validata a esposizione 0.45 (pos 0.15 lev 3) — a 0.5×2=1.0 + # girava a ~2.2x il validato (ETH/BTC DD grezzo 78%; ADA/ETH live -4.26% sleeve in + # un trade). A 0.20×2=0.40 ≈ validato: PORT06 OOS DD 3.40→1.26%, costo OOS Sharpe + # 9.05→8.43 (assicurazione, come il cap SHAPE). Gate: pairspos_port06_impact.py. + position_size_family: {PAIRS: 0.20} # Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo). # I 7 single-leg con TP/SL in metadata: 6 fade (MR01/MR02/MR07 x BTC/ETH) + # DIP01 BTC (attivato 2026-06-04: stesso wiring StrategyWorker, TP limit resting diff --git a/scripts/analysis/pairspos_port06_impact.py b/scripts/analysis/pairspos_port06_impact.py new file mode 100644 index 0000000..a56123e --- /dev/null +++ b/scripts/analysis/pairspos_port06_impact.py @@ -0,0 +1,147 @@ +"""GATE PORT06: position_size dei PAIRS live (improvement-sweep punto 8). + +Contesto. Tutta la validazione PR01 e' a pos=0.15 (lev 3 -> esposizione 0.45 della +fetta), ma il live gira col position_size GLOBALE 0.5 a leva 2 -> esposizione 1.0 +per coppia: ~2.2x il validato, su una famiglia SENZA stop (exit solo |z|<=z_exit o +max_bars) e col DD grezzo piu' alto (ETH/BTC ~48%). Scelta deliberata (commento yml) +ma mai gateata. Evidenza live: ADA/ETH 2026-06-05 net -8.52% = -4.26% sleeve a 0.5 +(sarebbe -1.28% a 0.15). + +Confronto, a livello PORT06 (pesi cap canonici PAIRS 0.33 + SHAPE 0.0588): le 5 +equity pairs ricostruite con pairs_sim alla LEVA LIVE (2x) e pos su griglia: + LIVE-OGGI pos=0.50 (esposizione 1.00) + P-0.25 pos=0.25 (0.50) + P-0.20 pos=0.20 (0.40) + P-0.15 pos=0.15 (0.30) <- pos canonica alla leva live +Ancora CANONICO (pos 0.15 lev 3, eff 0.45) = le equity di build_everything. + +Parita' preliminare: pairs_sim(pos=0.15, lev=3) deve riprodurre le equity canoniche. + +GATE (sweep): scegliere il punto sul trade-off DD/ritorno nel range 0.15-0.25; +promosso se vs LIVE-OGGI l'OOS Sharpe non peggiora e il DD (PORT06 e famiglia) scende. + + uv run python scripts/analysis/pairspos_port06_impact.py +""" +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 scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE, IDX +from scripts.analysis.pairs_research import pairs_sim +from scripts.analysis.report_families import daily_from +from scripts.strategies.PR01_pairs_reversion import PAIRS +from scripts.portfolios._defs import PORTFOLIOS +from src.portfolio import weighting as W + +LEV_LIVE = 2.0 +GRID = [0.50, 0.25, 0.20, 0.15] + + +def pair_equities(pos: float, lev: float): + """{PR_AB: (equity giornaliera, dd_grezzo%, ret%)} con pairs_sim a (pos, lev).""" + out = {} + for a, b, p in PAIRS: + r = pairs_sim(a, b, **{**p, "pos": pos, "lev": lev}) + # pairs_sim ritorna dd e ret GIA' in percento + out[f"PR_{a}{b}"] = (daily_from(r["eq_ts"], r["eq_v"]), + float(r["dd"]), float(r["ret"])) + return out + + +def port_metrics(members: dict[str, pd.Series], p): + ids = p.sleeve_ids + dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids}) + w = W.weight_vector(p.weighting, ids, dr, weights=p.weights, + caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback) + drp = port_returns({i: members[i] for i in ids}, w) + return metrics(drp), metrics(drp, lo=SPLIT) + + +def fam_metrics(eqs: dict[str, pd.Series]): + """Famiglia pairs standalone equal-weight.""" + dr = port_returns(eqs) + return metrics(dr), metrics(dr, lo=SPLIT) + + +def main(): + p = PORTFOLIOS["PORT06"] + pair_ids = [s.sid for s in p.sleeves if s.sid.startswith("PR_")] + print("=" * 100) + print(" GATE PORT06 — position_size PAIRS live (lev 2x) | validazione storica: pos 0.15 lev 3") + print(f" coppie: {pair_ids} | caps={p.caps}") + print("=" * 100) + + print("\n[1] build_everything() canonico (cache)...") + from src.portfolio.sleeves import all_sleeve_equities + eq_base = dict(all_sleeve_equities()) + + # --- [2] PARITA': pairs_sim(0.15, lev3) == canonico --- + print("\n[2] PARITA' pairs_sim(pos=0.15, lev=3) vs canonico:") + canon = pair_equities(0.15, 3.0) + parity_ok = True + for sid in pair_ids: + rep, _, _ = canon[sid] + base = eq_base[sid] + corr = base.pct_change().fillna(0).corr(rep.pct_change().fillna(0)) + rb = (base.iloc[-1] / base.iloc[0] - 1) * 100 + rr = (rep.iloc[-1] / rep.iloc[0] - 1) * 100 + flag = "" if (corr > 0.999 and abs(rr - rb) <= max(1.0, abs(rb) * 0.01)) else " <-- MISMATCH" + if flag: + parity_ok = False + print(f" {sid:<11s}corr={corr:.5f} ret canon {rb:+.0f}% vs replay {rr:+.0f}%{flag}") + print(f" PARITA' {'OK' if parity_ok else 'FALLITA'}.") + if not parity_ok: + print(" >>> STOP.") + return + + # --- [3] griglia pos alla leva live --- + print(f"\n[3] PORT06 e famiglia PAIRS per pos (lev={LEV_LIVE:.0f}x) | OOS da {OOS_DATE}") + print(f" {'variante':<12s}{'eff':>5s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR':>6s}" + f" | {'OOS Sh':>7s}{'OOS DD%':>8s} | {'famSh':>6s}{'famDD%':>7s}" + f" | {'worst pair DD% (grezzo)':>24s}") + print(" " + "-" * 96) + res = {} + for pos in GRID: + eqs = pair_equities(pos, LEV_LIVE) + members = dict(eq_base) + for sid in pair_ids: + members[sid] = eqs[sid][0] + f, o = port_metrics(members, p) + ff, _fo = fam_metrics({sid: eqs[sid][0] for sid in pair_ids}) + worst = max(((sid, eqs[sid][1]) for sid in pair_ids), key=lambda x: x[1]) + res[pos] = (f, o, ff, worst, eqs) + tag = "LIVE-OGGI" if pos == 0.50 else f"P-{pos:.2f}" + print(f" {tag:<12s}{pos*LEV_LIVE:>5.2f} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>5.0f}%" + f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f} | {ff['sharpe']:>6.2f}{ff['dd']:>7.1f}" + f" | {worst[0]} {worst[1]:>5.1f}") + + # --- [4] per-coppia al pos live vs candidato --- + print("\n[4] Per-coppia (DD grezzo % | ret %) a pos 0.50 vs 0.20:") + e50, e20 = res[0.50][4], res[0.20][4] + for sid in pair_ids: + print(f" {sid:<11s} DD {e50[sid][1]:>5.1f} -> {e20[sid][1]:>5.1f} " + f"ret {e50[sid][2]:>+8.0f}% -> {e20[sid][2]:>+8.0f}%") + + # --- GATE: candidati vs LIVE-OGGI --- + f_l, o_l = res[0.50][0], res[0.50][1] + print("\n" + "=" * 100) + print(" GATE vs LIVE-OGGI (pos 0.50): OOS Sharpe non peggiora E DD scende (PORT06)") + print("=" * 100) + for pos in (0.25, 0.20, 0.15): + f_c, o_c = res[pos][0], res[pos][1] + ok = (o_c["sharpe"] >= o_l["sharpe"] - 0.02 and o_c["dd"] < o_l["dd"] + and f_c["sharpe"] >= f_l["sharpe"] - 0.02) + print(f" pos {pos:.2f}: OOS Sh {o_l['sharpe']:.2f}->{o_c['sharpe']:.2f} " + f"DD {o_l['dd']:.2f}->{o_c['dd']:.2f} | FULL Sh {f_l['sharpe']:.2f}->{f_c['sharpe']:.2f} " + f"DD {f_l['dd']:.2f}->{f_c['dd']:.2f} => {'PROMOSSO' if ok else 'bocciato'}") + + +if __name__ == "__main__": + main() diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index 6f6ce69..83d4794 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -40,6 +40,13 @@ _LOOKBACK_DAYS = {"1h": 90, "4h": 220, "1d": 440} _ML_LOOKBACK_DAYS = 365 +def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float]) -> float: + """position_size effettivo di uno sleeve: override per-famiglia (chiave = + weighting.family_of: PAIRS/FADE/HONEST/SHAPE/TSM) o globale.""" + from src.portfolio.weighting import family_of + return family_overrides.get(family_of(sid), global_ps) + + def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, data_dir: Path = DATA_DIR, position_size: float = 0.15, executor=None, exec_instrument: str | None = None): @@ -187,6 +194,12 @@ def run(config_path: str = "portfolios.yml"): # Con leva 2x: notional = capital * position_size * 2. A 0.5 ogni sleeve in posizione # impegna il 100% della sua fetta (max impiego senza debito di margine); DD scala ~lineare. position_size = float(_ov.get("position_size", 0.15)) + # Override PER-FAMIGLIA (improvement-sweep punto 8): la chiave e' la famiglia di + # weighting.family_of (PAIRS/FADE/HONEST/SHAPE/TSM). Nato per i pairs: tutta la + # validazione PR01 e' a pos 0.15 e la famiglia e' SENZA stop -> il pos globale 0.5 + # la faceva girare a ~2.2x l'esposizione validata. Gate: pairspos_port06_impact.py. + ps_family = {str(k).upper(): float(v) + for k, v in (_ov.get("position_size_family") or {}).items()} def _supported(s): return s.kind in ("pairs",) + _MULTI_KINDS or s.name in _STRAT_MODULE @@ -229,8 +242,10 @@ def run(config_path: str = "portfolios.yml"): for s in live_specs: ex, inst = _exec_for(s) workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage, - position_size=position_size, + position_size=pos_for_spec(s.sid, position_size, ps_family), executor=ex, exec_instrument=inst) + if ps_family: + print(f"[runner] position_size globale={position_size} override famiglia={ps_family}") # lookback (giorni) richiesto per ogni asset = max sui worker che lo usano asset_days: dict[str, int] = {} diff --git a/tests/portfolio/test_runner_build.py b/tests/portfolio/test_runner_build.py index 71c7288..a232431 100644 --- a/tests/portfolio/test_runner_build.py +++ b/tests/portfolio/test_runner_build.py @@ -1,4 +1,4 @@ -from src.portfolio.runner import build_worker_for +from src.portfolio.runner import build_worker_for, pos_for_spec from src.portfolio.base import SleeveSpec from src.live.strategy_worker import StrategyWorker from src.live.pairs_worker import PairsWorker @@ -20,6 +20,26 @@ def test_build_pairs_worker(tmp_path): assert w.capital == 200.0 +def test_pos_for_spec_family_override(): + """position_size per-famiglia (improvement-sweep punto 8): l'override PAIRS si + applica ai soli PR_*, gli altri sleeve restano sul globale.""" + fam = {"PAIRS": 0.20} + assert pos_for_spec("PR_ETHBTC", 0.5, fam) == 0.20 + assert pos_for_spec("PR_ADAETH", 0.5, fam) == 0.20 + assert pos_for_spec("MR01_BTC", 0.5, fam) == 0.5 # FADE -> globale + assert pos_for_spec("SH_BTC", 0.5, fam) == 0.5 # SHAPE -> globale + assert pos_for_spec("TSM01", 0.5, fam) == 0.5 + assert pos_for_spec("PR_ETHBTC", 0.5, {}) == 0.5 # nessun override + + +def test_build_pairs_worker_position_size(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, + position_size=0.20, data_dir=tmp_path) + assert w.position_size == 0.20 + + def test_build_ml_sh01_is_plain_strategyworker(tmp_path): """SH01 (kind=ml) deve costruirsi come StrategyWorker che esegue SH01_shape_ml — NON il vecchio MLWorkerWrapper (SignalEngine squeeze scartato, che ignorava la strategia ed usciva