Files
PythagorasGoal/scripts/analysis/pairspos_port06_impact.py
T
Adriano Dal Pastro 6097205694 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>
2026-06-07 10:44:15 +00:00

148 lines
6.2 KiB
Python

"""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()