"""Drift monitor per-famiglia — il rolling-return corrente di ogni famiglia vs la DISTRIBUZIONE STORICA dei propri rolling-return (stessa finestra, storia 2021+). Non è un filtro di trading: è OSSERVABILITÀ (la protezione giusta contro il drift è accorgersene presto, non ritoccare i parametri — lezione 2026-06-11: le FADE al 2° percentile sul 120g sono state trovate a mano; questo script lo rende ripetibile). Percentile basso = la famiglia sta attraversando uno dei suoi tratti peggiori: - sotto P_WARN (5%): segnalato — coerente con la coda storica, OSSERVARE; - il PORT06 complessivo sotto P_WARN è più serio (la diversificazione non copre). Equity dal builder canonico (all_sleeve_equities → parità coi gate). uv run python scripts/analysis/drift_monitor.py # stampa uv run python scripts/analysis/drift_monitor.py --telegram # + invio Telegram """ 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 from scripts.portfolios._defs import PORTFOLIOS from src.portfolio.sleeves import all_sleeve_equities from src.portfolio import weighting as W WINDOWS = (60, 120) # giorni P_WARN = 5.0 # percentile sotto cui segnalare def family_returns(): """Rendimenti daily per famiglia (equal-weight intra-famiglia) + PORT06 (pesi cap).""" p = PORTFOLIOS["PORT06"] eq = dict(all_sleeve_equities()) ids = list(p.sleeve_ids) fams: dict[str, list] = {} for i in ids: fams.setdefault(W.family_of(i), []).append(i) out = {} for f, members in sorted(fams.items()): out[f] = port_returns({i: eq[i] for i in members}, {i: 1 / len(members) for i in members}) dr = pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids}) w = W.weight_vector("cap", ids, dr, caps=p.caps, clusters=p.clusters) out["PORT06"] = port_returns({i: eq[i] for i in ids}, w) return out def drift_rows(): rows = [] for name, r in family_returns().items(): for win in WINDOWS: roll = (1 + r).rolling(win).apply(np.prod, raw=True) - 1 roll = roll.dropna() if len(roll) < 100: continue cur = float(roll.iloc[-1]) pct = float((roll < cur).mean() * 100) rows.append(dict(name=name, win=win, cur=cur * 100, pct=pct, p5=float(roll.quantile(0.05) * 100), med=float(roll.median() * 100))) return rows def build_report(rows) -> tuple[str, bool]: warn = [r for r in rows if r["pct"] < P_WARN] L = ["📉 Drift monitor — rolling-return vs storia propria (2021+)"] L.append("
" + f"{'famiglia':<9}{'win':>5}{'corr%':>8}{'pct':>6}{'p5%':>8}{'med%':>7}")
    for r in rows:
        flag = " ⚠️" if r["pct"] < P_WARN else ""
        L.append(f"{r['name']:<9}{r['win']:>4}g{r['cur']:>+8.1f}{r['pct']:>5.0f}%"
                 f"{r['p5']:>+8.1f}{r['med']:>+7.1f}{flag}")
    L.append("
") if warn: names = ", ".join(f"{r['name']} {r['win']}g (p{r['pct']:.0f})" for r in warn) L.append(f"⚠️ sotto il p{P_WARN:.0f} storico: {names} — coda storica della famiglia: " "OSSERVARE, non ritoccare i parametri (drift ≠ rottura; " "vedi docs/diary/2026-06-11-stability-sweep.md)") else: L.append(f"✅ tutte le famiglie sopra il p{P_WARN:.0f} storico") return "\n".join(L), bool(warn) def main(): rows = drift_rows() report, warned = build_report(rows) import re print(re.sub(r"", "", report)) if "--telegram" in sys.argv: from src.live.telegram_notifier import send_telegram ok = send_telegram(report) print(f"[telegram] inviato: {ok}") return warned if __name__ == "__main__": main()