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:
@@ -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()
|
||||
Reference in New Issue
Block a user