feat(pairs): position_size per-famiglia, PAIRS 0.5->0.20 (gate PORT06)

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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-07 10:44:15 +00:00
parent 0aaab08612
commit 6097205694
6 changed files with 242 additions and 2 deletions
+6
View File
@@ -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). `PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET).
Diario `docs/diary/2026-06-07-sweep-fixes.md`. 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`. - **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. - **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 ## Multi-Strategy Paper Trader
+46
View File
@@ -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).
+6
View File
@@ -15,6 +15,12 @@ overrides:
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego # 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). # dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
position_size: 0.5 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). # 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) + # 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 # DIP01 BTC (attivato 2026-06-04: stesso wiring StrategyWorker, TP limit resting
+147
View File
@@ -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()
+16 -1
View File
@@ -40,6 +40,13 @@ _LOOKBACK_DAYS = {"1h": 90, "4h": 220, "1d": 440}
_ML_LOOKBACK_DAYS = 365 _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, def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
data_dir: Path = DATA_DIR, position_size: float = 0.15, data_dir: Path = DATA_DIR, position_size: float = 0.15,
executor=None, exec_instrument: str | None = None): 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 # 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. # impegna il 100% della sua fetta (max impiego senza debito di margine); DD scala ~lineare.
position_size = float(_ov.get("position_size", 0.15)) 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): def _supported(s):
return s.kind in ("pairs",) + _MULTI_KINDS or s.name in _STRAT_MODULE 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: for s in live_specs:
ex, inst = _exec_for(s) ex, inst = _exec_for(s)
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage, 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) 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 # lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
asset_days: dict[str, int] = {} asset_days: dict[str, int] = {}
+21 -1
View File
@@ -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.portfolio.base import SleeveSpec
from src.live.strategy_worker import StrategyWorker from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker from src.live.pairs_worker import PairsWorker
@@ -20,6 +20,26 @@ def test_build_pairs_worker(tmp_path):
assert w.capital == 200.0 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): def test_build_ml_sh01_is_plain_strategyworker(tmp_path):
"""SH01 (kind=ml) deve costruirsi come StrategyWorker che esegue SH01_shape_ml — NON il """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 vecchio MLWorkerWrapper (SignalEngine squeeze scartato, che ignorava la strategia ed usciva