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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user