diff --git a/docs/TODO.md b/docs/TODO.md index 0bbc209..ed6876f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -41,19 +41,27 @@ - [x] ~~forming-bar su ROT02/TSM01~~ — GIÀ FIXATO (v1.1.10, 2026-06-08): `_panel` condiviso scarta la barra in formazione via `last_bar_is_forming`. Item rimasto stantio nel TODO. -- [ ] **ancora bfill di `_daily_equity`**: la serie a punti-trade reindexata su IDX àncora il primo - valore della finestra al PRIMO trade in-finestra, non al capitale portato avanti. Tocca le - metriche canoniche di TUTTI gli sleeve a punti-trade (honest/pairs/tsm). Valutare se - correggere OVUNQUE in un colpo (cambierebbe i numeri canonici di riferimento). Scoperto nel - gate DIP01 (parità inizialmente fallita per questo). +- [ ] **ancora bfill di `_daily_equity`** — QUANTIFICATO 2026-06-11 (`daily_equity_bfill_impact.py`): + **NON materiale, lasciare documentato**. PORT06 OOS invariato per costruzione (il bias vive + in testa alla serie, pre-SPLIT; ΔSharpe +0.001, ΔDD 0.000); FULL DD leggermente OTTIMISTICO + (3.46→3.67 col fix: il primo trade DIP01 2021, −3.75%, è nascosto dal bfill). Nessun verdetto + di gate a rischio (bias identico nei due bracci A-vs-B, si cancella). Unica eccezione OOS: + TSM01 (primo punto equity post-SPLIT, −0.014 Sh). Correggere SOLO in un eventuale refactor + del builder daily, OVUNQUE in un colpo (~12 file di gate replicano la convenzione) e + ri-baselinando i canonici nello stesso commit. CAVEAT per gate futuri: finestre IDX che + partono a metà storia amplificano il bug → usare lì la convenzione carry-forward corretta. - [x] ~~convenzione TR01 worker vs reference~~ — ERA UN BUG, FIXATO (2026-06-11): `mean(rets)` sui soli asset in posizione sovrappesava N/k a paniere parziale (con 1 long: 0.45 del capitale invece di 0.09). Fix: `sum(rets)/len(universe)` (equal-weight 1/N canonico). Replay post-fix: +32% vs reference +42% (residuo = convenzione capitale-unico vs media-equity, dichiarata). Diario `2026-06-11-stability-sweep.md`. -- [ ] **engine duplicato nei 3 gate** `*_port06_impact.py` (exit16/trendmax/dip01): `build_trades_variant` - / `port_metrics` copiati quasi verbatim. Sono regression-lock per decisioni live → la - copy-drift corrompe i verdetti. Fattorizzare in un modulo condiviso. +- [x] ~~engine duplicato nei 3 gate~~ — FATTO (2026-06-11): fattorizzato in + `scripts/analysis/_port06_gate_common.py` (`build_trades_variant` versione trendmax = + superset con hurst_mask/trend_max parametrici, `equity_from_trades`, `port_metrics`, + `dd`); i 3 gate importano da lì. Nessuna drift di matematica trovata fra le copie + (solo firme/docstring). Output dei 3 gate verificato BYTE-IDENTICO prima/dopo. + `dip_trades` (DIP01) NON è una copia ma un sibling deliberato (long-only, orig_gap, + j clampato) → resta nel suo script, documentato nel modulo comune. - [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE): ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config. diff --git a/scripts/analysis/_port06_gate_common.py b/scripts/analysis/_port06_gate_common.py new file mode 100644 index 0000000..3d33048 --- /dev/null +++ b/scripts/analysis/_port06_gate_common.py @@ -0,0 +1,142 @@ +"""REGRESSION-LOCK COMUNE dei gate PORT06 live (exit16 / trendmax / dip01). + +Queste funzioni erano copiate quasi-verbatim in exit16_port06_impact.py, +trendmax_port06_impact.py e dip01_exit16_impact.py. Sono il regression-lock +delle DECISIONI LIVE (EXIT-16, swap hurst->trend, DIP01 EXIT-16): la copy-drift +fra le copie avrebbe corrotto i verdetti, quindi vivono qui in un'unica copia. + +NON cambiare la matematica: i gate devono restare riproducibili byte-a-byte. +Se un nuovo gate richiede un comportamento diverso, PARAMETRIZZARE (come fu +fatto per hurst_mask/trend_max), mai biforcare una copia. + +Contenuto: + build_trades_variant : replay ESATTO di risk_management.build_trades sulle + fade (mode="orig" == canonico), con i rami varianti + EXIT-16 (mode="exit16"), filtro trend (trend_max) e + loss-guard Hurst (hurst_mask) parametrici. + equity_from_trades : trade -> equity giornaliera normalizzata su IDX + (stesso flusso di combine_portfolio.fade_daily_equity). + port_metrics : metriche FULL/OOS del portafoglio con la STESSA + matematica pesi di Portfolio.backtest (weight_vector + su tutti gli sleeve, ribilancio come port_returns). + dd : max drawdown % di una equity. + +NB: l'engine DIP01 (dip_trades in dip01_exit16_impact.py) NON e' una copia di +build_trades_variant ma un sibling deliberatamente diverso (long-only, mode +"orig_gap" gap-aware, j clampato a n-1 a fine serie, niente filtri trend/hurst) +-> resta nel suo script. +""" +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.strategy_research import atr +from scripts.analysis.risk_management import FEE_RT, LEV, POS, INIT +from scripts.analysis.combine_portfolio import ( + _norm, IDX, port_returns, metrics, SPLIT, +) +from src.portfolio import weighting as W + +BUFFER = 0.5 # EXIT-16 close-confirm (come in produzione) +EMA_LONG = 200 + + +def build_trades_variant(ents, df, mode, trend_max, hurst_mask=None, + buffer=BUFFER, lev=LEV, fee_rt=FEE_RT, ema_long=EMA_LONG): + """Replica ESATTA di risk_management.build_trades, con i rami varianti. + + mode="orig" : SL intrabar al livello (SL prima del TP) == canonico. + mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar); + SL solo se il CLOSE sfonda sl0 -/+ buffer*ATR14[j], fill a close[j]. + trend_max : None = filtro OFF; 3.0 = config live. + hurst_mask : bool[i]=True -> salta l'ingresso (loss-guard storico). + """ + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c) + a = atr(df, 14) + el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values + fee = fee_rt * lev + out = [] + last = -1 + for e in ents: + i, d = e["i"], e["d"] + if i <= last or i + 1 >= n: + continue + if hurst_mask is not None and hurst_mask[i]: + continue + if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max: + continue + entry = c[i] + tp, sl0, mb = e["tp"], e["sl"], e["max_bars"] + exit_p = c[min(i + mb, n - 1)] + j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1] + break + if mode == "orig": + hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0) + ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) + if hs: + exit_p = sl0 + break + if ht: + exit_p = tp + break + if k == mb: + exit_p = c[j] + else: # exit16 + ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp) + if ht: + exit_p = tp + break + aj = a[j] if np.isfinite(a[j]) else 0.0 + confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \ + (d == -1 and c[j] > sl0 + buffer * aj) + if confirm: + exit_p = c[j] + break + if k == mb: + exit_p = c[j] + ret = (exit_p - entry) / entry * d * lev - fee + out.append((i, j, ret)) + last = j + return out + + +def equity_from_trades(df, trades): + """Trade -> equity giornaliera su IDX (flusso di combine_portfolio.fade_daily_equity).""" + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + n = len(df) + eq = np.full(n, INIT, dtype=float) + cap = INIT + for i, j, ret in sorted(trades, key=lambda t: t[1]): + cap = max(cap + cap * POS * ret, 10.0) + eq[j:] = cap + s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill() + return _norm(s) + + +def port_metrics(members: dict[str, pd.Series], p): + """Metriche (FULL, OOS) del portafoglio p con la STESSA matematica pesi cap + di Portfolio.backtest.""" + 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 dd(s): + """Max drawdown % di una serie equity.""" + pk = s.cummax() + return float(((pk - s) / pk).max() * 100) diff --git a/scripts/analysis/daily_equity_bfill_impact.py b/scripts/analysis/daily_equity_bfill_impact.py new file mode 100644 index 0000000..b1939ad --- /dev/null +++ b/scripts/analysis/daily_equity_bfill_impact.py @@ -0,0 +1,209 @@ +"""ANALISI DI IMPATTO (sola lettura, da docs/TODO.md): bug bfill di `_daily_equity`. + +IL BUG (scripts/analysis/honest_improve2.py:30): + daily = s.resample("1D").last().reindex(idx).ffill().bfill() +La serie `s` e' a PUNTI-TRADE (un valore di capitale per ogni exit). Il `reindex(idx)` +taglia PRIMA di forward-fillare: i giorni di IDX precedenti al primo trade DENTRO la +finestra restano NaN (il ffill non ha un valore precedente in-finestra da propagare) e +il `.bfill()` finale li riempie col capitale DOPO il primo trade in-finestra. Effetti: + 1. l'ancora a idx[0] e' il capitale post-primo-trade-in-finestra, NON il capitale + portato avanti dall'ultimo trade PRIMA della finestra; + 2. il rendimento del primo trade in-finestra viene CANCELLATO dalla serie daily + (la testa e' piatta al valore post-trade -> pct_change = 0 anche il giorno del trade). + +CORREZIONE (qui, solo per confronto): ffill PRIMA del reindex (carry-forward su tutta la +storia trade) + testa pre-primo-trade-assoluto = capitale iniziale 1000. MAI valori dal futuro. + +Sleeve canonici interessati (serie a punti-trade -> testa di IDX scoperta): + DIP01_BTC, PR_ETHBTC, PR_ETHBTC_15M, PR_LTCETH, PR_ADAETH, PR_BTCLTC, PR_ETHSOL, + TSM01, XS01 (questi due quasi-densi: punti daily/12h -> impatto atteso ~0). +TR01_basket / ROT02_rot passano da _daily_equity ma con punti PER-BARRA (densi dal +2018) -> verificati comunque qui via monkeypatch runtime (nessun file canonico toccato). +I fade (combine_portfolio.py:52) e SH01 (shape_ml_validate.py:124) usano lo stesso +pattern reindex+bfill ma su equity PER-BARRA con dati che iniziano prima di IDX[0] +-> il bfill e' un no-op (verificato: nessun NaN in testa). + +NB: le metriche OOS canoniche affettano la STESSA serie daily a SPLIT (metrics(dr, +lo=SPLIT)); la distorsione sta solo in testa (2021) -> l'OOS e' invariato per +costruzione se il primo trade in-finestra precede lo SPLIT. Questo script lo misura. + +Uso: uv run python scripts/analysis/daily_equity_bfill_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)) + +import scripts.analysis.honest_improve2 as hi2 +from scripts.analysis.honest_improve2 import _norm, dip_market_gated +from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns +from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat +from scripts.analysis.tsmom_research import tsmom_sim +from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIR_DEFS +from scripts.strategies.XS01_cross_sectional import xsec_sim +from scripts.portfolios._defs import PORTFOLIOS +from src.portfolio import weighting as W + +INIT = 1000.0 + + +# ---------------- le due convenzioni ---------------- +def daily_equity_buggy(ts_list, cap_list, idx): + """Replica ESATTA di honest_improve2._daily_equity (per parity-check).""" + s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True)) + s = s[~s.index.duplicated(keep="last")].sort_index() + return s.resample("1D").last().reindex(idx).ffill().bfill() + + +def daily_equity_fixed(ts_list, cap_list, idx, init=INIT): + """CORRETTA: ancora = capitale portato avanti dall'ultimo trade PRIMA della + finestra (ffill prima del reindex); pre-primo-trade assoluto = capitale iniziale.""" + s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True)) + s = s[~s.index.duplicated(keep="last")].sort_index() + daily = s.resample("1D").last().ffill() # carry-forward su TUTTA la storia + daily = daily.reindex(idx).ffill() # coda oltre l'ultimo trade + return daily.fillna(init) # testa pre-primo-trade: capitale iniziale + + +def head_info(ts_list, cap_list, idx): + """(primo giorno con trade dentro IDX, rendimento di testa perso dal bfill %).""" + s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True)) + s = s[~s.index.duplicated(keep="last")].sort_index() + raw = s.resample("1D").last().reindex(idx) # senza fill: NaN = nessun trade quel giorno + first = raw.first_valid_index() + if first is None: + return None, 0.0 + fixed = daily_equity_fixed(ts_list, cap_list, idx) + lost = (fixed.loc[first] / fixed.iloc[0] - 1) * 100 # ritorno idx[0]->primo trade-day + return first.date(), float(lost) + + +def m2(eq: pd.Series): + dr = eq.pct_change().fillna(0.0) + return metrics(dr), metrics(dr, lo=SPLIT) + + +def fmt_pair(label, b, f): + d_sh = f["sharpe"] - b["sharpe"] + d_dd = f["dd"] - b["dd"] + d_rt = f["ret"] - b["ret"] + return (f" {label:<22s}" + f"Sh {b['sharpe']:6.2f}->{f['sharpe']:6.2f} ({d_sh:+.3f}) " + f"DD {b['dd']:6.2f}->{f['dd']:6.2f} ({d_dd:+.3f}pp) " + f"ret {b['ret']:+9.1f}->{f['ret']:+9.1f} ({d_rt:+8.2f}pp)") + + +def main(): + print("=" * 110) + print(" IMPATTO bug bfill _daily_equity (honest_improve2.py:30) — attuale vs corretto") + print(f" IDX {IDX[0].date()} -> {IDX[-1].date()} | OOS da {OOS_DATE} (slice a SPLIT={SPLIT} sui rendimenti daily)") + print("=" * 110) + + # ---------------- [1] baseline canonica (bfill cosi' com'e') ---------------- + print("\n[1] build_everything() canonico (2-3 min)...") + from scripts.analysis.report_families import build_everything + S, pairs, tsm, shape = build_everything() + base = {**S, **pairs, **tsm, **shape} + + # ---------------- [2] ri-simula gli sleeve a punti-trade ---------------- + print("[2] ri-simulazione sleeve a punti-trade (parity-check + versione corretta)...") + raw: dict[str, tuple] = {} + d = dip_market_gated("BTC", market_n=0, return_equity=True) + raw["DIP01_BTC"] = (d["eq_ts"], d["eq_v"]) + for a, b_, p in PAIR_DEFS: + r = pairs_sim(a, b_, **p) + raw[f"PR_{a}{b_}"] = (r["eq_ts"], r["eq_v"]) + r15 = pairs_sim_flat("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0, + max_bars=35, flat_skip=True, pos=0.075) + raw["PR_ETHBTC_15M"] = (r15["eq_ts"], r15["eq_v"]) + t = tsmom_sim() + raw["TSM01"] = (t["eq_ts"], t["eq_v"]) + x = xsec_sim() + raw["XS01"] = (x["eq_ts"], x["eq_v"]) + + fixed: dict[str, pd.Series] = {} + print(f"\n {'sleeve':<16s}{'parity(max|diff|)':>18s}{'1o trade in IDX':>17s}{'ret testa perso%':>18s}") + for k, (ts, v) in raw.items(): + bug = _norm(daily_equity_buggy(ts, v, IDX)) + par = float((bug - base[k]).abs().max()) + fixed[k] = _norm(daily_equity_fixed(ts, v, IDX)) + first, lost = head_info(ts, v, IDX) + flag = "" if par < 1e-9 else " <-- PARITY FAIL" + print(f" {k:<16s}{par:>18.2e}{str(first):>17s}{lost:>+18.3f}{flag}") + + # TR01/ROT02: passano da _daily_equity ma con punti per-barra (densi) -> + # ricalcolo con monkeypatch RUNTIME della funzione (nessun file toccato). + orig_de = hi2._daily_equity + try: + hi2._daily_equity = daily_equity_fixed + tr_f = _norm(hi2._tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX)) + rot_f = _norm(hi2._rot_daily_equity(IDX)) + finally: + hi2._daily_equity = orig_de + for k, sf in (("TR01_basket", tr_f), ("ROT02_rot", rot_f)): + diff = float((sf - base[k]).abs().max()) + print(f" {k:<16s}{'(denso)':>18s}{'—':>17s}{diff:>18.2e} (diff fixed-vs-base: atteso ~0)") + fixed[k] = sf + + # ---------------- [3] metriche per sleeve: attuale vs corretto ---------------- + print("\n" + "=" * 110) + print(" (3) SLEEVE a punti-trade — FULL e OOS, attuale(bfill) -> corretto(carry-forward)") + print("=" * 110) + rows_oos_delta = {} + for k in fixed: + bf, bo = m2(base[k]) + ff, fo = m2(fixed[k]) + print(fmt_pair(f"{k} FULL", bf, ff)) + print(fmt_pair(f"{k} OOS ", bo, fo)) + rows_oos_delta[k] = (ff["sharpe"] - bf["sharpe"], ff["dd"] - bf["dd"], + fo["sharpe"] - bo["sharpe"], fo["dd"] - bo["dd"]) + + # ---------------- [4] PORT06: attuale vs corretto ---------------- + print("\n" + "=" * 110) + print(" (4) PORT06 (cap PAIRS 0.33 + SHAPE 0.0588) — attuale vs corretto") + print("=" * 110) + p = PORTFOLIOS["PORT06"] + + def port_m(members): + 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) + + members_fix = {**base, **fixed} + bf, bo = port_m(base) + ff, fo = port_m(members_fix) + print(fmt_pair("PORT06 FULL", bf, ff)) + print(fmt_pair("PORT06 OOS ", bo, fo)) + + # ---------------- [5] verdetto ---------------- + print("\n" + "=" * 110) + print(" (5) VERDETTO (soglie materialita': >0.1 Sharpe o >0.5pp DD su PORT06 OOS)") + print("=" * 110) + d_sh_oos = abs(fo["sharpe"] - bo["sharpe"]) + d_dd_oos = abs(fo["dd"] - bo["dd"]) + d_sh_full = abs(ff["sharpe"] - bf["sharpe"]) + d_dd_full = abs(ff["dd"] - bf["dd"]) + materiale = d_sh_oos > 0.1 or d_dd_oos > 0.5 + print(f" PORT06 OOS : dSharpe {fo['sharpe']-bo['sharpe']:+.4f} dDD {fo['dd']-bo['dd']:+.4f}pp" + f" -> {'MATERIALE' if materiale else 'NON materiale'}") + print(f" PORT06 FULL: dSharpe {ff['sharpe']-bf['sharpe']:+.4f} dDD {ff['dd']-bf['dd']:+.4f}pp") + worst = sorted(rows_oos_delta.items(), key=lambda kv: -abs(kv[1][0]) - abs(kv[1][1]) / 10) + print(" Sleeve piu' toccati (dSharpe FULL, dDD FULL, dSharpe OOS, dDD OOS):") + for k, (ds, dd_, dso, ddo) in worst[:5]: + print(f" {k:<16s} FULL {ds:+.3f} / {dd_:+.3f}pp OOS {dso:+.3f} / {ddo:+.3f}pp") + print("\n Nota strutturale: l'OOS canonico e' uno slice a SPLIT della stessa serie daily;") + print(" la distorsione bfill vive solo in testa (prima del primo trade in IDX) -> se il") + print(" primo trade in-finestra precede lo SPLIT, l'OOS e' INVARIATO per costruzione.") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/dip01_exit16_impact.py b/scripts/analysis/dip01_exit16_impact.py index 4793da9..e8ae267 100644 --- a/scripts/analysis/dip01_exit16_impact.py +++ b/scripts/analysis/dip01_exit16_impact.py @@ -33,11 +33,9 @@ sys.path.insert(0, str(PROJECT_ROOT)) from src.data.downloader import load_data from scripts.analysis.strategy_research import atr -from scripts.analysis.combine_portfolio import ( - _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE, -) +from scripts.analysis.combine_portfolio import _norm, IDX, metrics, SPLIT, OOS_DATE +from scripts.analysis._port06_gate_common import port_metrics from scripts.portfolios._defs import PORTFOLIOS -from src.portfolio import weighting as W FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0 BUFFER = 0.5 @@ -139,15 +137,6 @@ def cell_metrics(eq): return metrics(dr), metrics(dr, lo=SPLIT) -def port_metrics(members, 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 main(): p = PORTFOLIOS["PORT06"] print("=" * 104) diff --git a/scripts/analysis/drift_monitor.py b/scripts/analysis/drift_monitor.py new file mode 100644 index 0000000..ea4c5f6 --- /dev/null +++ b/scripts/analysis/drift_monitor.py @@ -0,0 +1,102 @@ +"""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"?(b|pre)>", "", 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()
diff --git a/scripts/analysis/exit16_port06_impact.py b/scripts/analysis/exit16_port06_impact.py
index 589b4e3..d31fc7a 100644
--- a/scripts/analysis/exit16_port06_impact.py
+++ b/scripts/analysis/exit16_port06_impact.py
@@ -24,106 +24,23 @@ 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 src.data.downloader import load_data
-from scripts.analysis.strategy_research import atr
-from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
-from scripts.analysis.combine_portfolio import (
- fade_daily_equity, _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
+from scripts.analysis.risk_management import strats_for
+from scripts.analysis.combine_portfolio import OOS_DATE
+from scripts.analysis._port06_gate_common import (
+ build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
)
from scripts.portfolios._defs import PORTFOLIOS
-from src.portfolio import weighting as W
-
-BUFFER = 0.5 # EXIT-16 promossa: close-confirm con buffer 0.5 ATR
-
-
-# ---------------------------------------------------------------- engine replay
-def build_trades_variant(ents, df, mode, buffer=BUFFER,
- lev=LEV, fee_rt=FEE_RT, trend_max=3.0, ema_long=200):
- """Replica ESATTA di risk_management.build_trades, cambiando SOLO il ramo SL.
-
- mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
- mode="exit16" : SL intrabar DISATTIVATO; close-confirm sul close[j]:
- long esci a close[j] se close[j] < sl0 - buffer*atr14[j]
- short esci a close[j] se close[j] > sl0 + buffer*atr14[j]
- TP intrabar al livello e max_bars al close INVARIATI.
- """
- h, l, c = df["high"].values, df["low"].values, df["close"].values
- n = len(c)
- a = atr(df, 14)
- el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
- fee = fee_rt * lev
- out = []
- last = -1
- for e in ents:
- i, d = e["i"], e["d"]
- if i <= last or i + 1 >= n:
- continue
- if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
- continue
- entry = c[i]
- tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
- exit_p = c[min(i + mb, n - 1)]
- j = min(i + mb, n - 1)
- for k in range(1, mb + 1):
- j = i + k
- if j >= n:
- exit_p = c[n - 1]
- break
- if mode == "orig":
- hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
- ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
- if hs:
- exit_p = sl0
- break
- if ht:
- exit_p = tp
- break
- if k == mb:
- exit_p = c[j]
- else: # exit16: no SL intrabar; TP intrabar; poi close-confirm SL al close[j]
- ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
- if ht:
- exit_p = tp
- break
- aj = a[j] if np.isfinite(a[j]) else 0.0
- confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
- (d == -1 and c[j] > sl0 + buffer * aj)
- if confirm:
- exit_p = c[j]
- break
- if k == mb:
- exit_p = c[j]
- ret = (exit_p - entry) / entry * d * lev - fee
- out.append((i, j, ret))
- last = j
- return out
def fade_equity_variant(asset, fn, params, mode):
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
df = load_data(asset, "1h")
- ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
- n = len(df)
- eq = np.full(n, INIT, dtype=float)
- cap = INIT
- for i, j, ret in sorted(trades, key=lambda t: t[1]):
- cap = max(cap + cap * POS * ret, 10.0)
- eq[j:] = cap
- s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
- return _norm(s)
-
-
-# ---------------------------------------------------------------- pesi PORT06
-def port_metrics(members: dict[str, pd.Series], weights: dict[str, float]):
- dr = port_returns(members, weights)
- return metrics(dr), metrics(dr, lo=SPLIT)
+ return equity_from_trades(df, trades)
def main():
@@ -174,17 +91,9 @@ def main():
for sid in fade_ids:
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
- ids = p.sleeve_ids
- # pesi cap canonici (gli stessi che usa Portfolio.backtest)
- dr_base = pd.DataFrame({i: members_base[i].pct_change().fillna(0.0) for i in ids})
- w_base = W.weight_vector(p.weighting, ids, dr_base, weights=p.weights,
- caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
- dr_e16 = pd.DataFrame({i: members_e16[i].pct_change().fillna(0.0) for i in ids})
- w_e16 = W.weight_vector(p.weighting, ids, dr_e16, weights=p.weights,
- caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
-
- f_b, o_b = port_metrics({i: members_base[i] for i in ids}, w_base)
- f_e, o_e = port_metrics({i: members_e16[i] for i in ids}, w_e16)
+ # pesi cap canonici (gli stessi che usa Portfolio.backtest) dentro port_metrics
+ f_b, o_b = port_metrics(members_base, p)
+ f_e, o_e = port_metrics(members_e16, p)
print("\n" + "=" * 96)
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
@@ -207,8 +116,6 @@ def main():
f"{'orig DD%':>10s}{'e16 DD%':>10s}")
for sid in fade_ids:
ro = eq_orig[sid]; re = eq_e16[sid]
- def _dd(s):
- pk = s.cummax(); return float(((pk - s) / pk).max() * 100)
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
diff --git a/scripts/analysis/trendmax_port06_impact.py b/scripts/analysis/trendmax_port06_impact.py
index a73ec1b..1cdef38 100644
--- a/scripts/analysis/trendmax_port06_impact.py
+++ b/scripts/analysis/trendmax_port06_impact.py
@@ -29,116 +29,20 @@ 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 src.data.downloader import load_data
from src.strategies.fade_base import hurst_skip_mask
-from scripts.analysis.strategy_research import atr
-from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
-from scripts.analysis.combine_portfolio import (
- _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
+from scripts.analysis.risk_management import strats_for
+from scripts.analysis.combine_portfolio import OOS_DATE
+from scripts.analysis._port06_gate_common import (
+ build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
)
from scripts.portfolios._defs import PORTFOLIOS
-from src.portfolio import weighting as W
-BUFFER = 0.5 # EXIT-16 close-confirm (come in produzione)
HURST_MAX = 0.55 # loss-guard live
TREND_MAX = 3.0
-EMA_LONG = 200
-
-
-def build_trades_variant(ents, df, mode, trend_max, hurst_mask=None,
- buffer=BUFFER, lev=LEV, fee_rt=FEE_RT, ema_long=EMA_LONG):
- """Engine di exit16_port06_impact.build_trades_variant + skip Hurst opzionale.
-
- mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
- mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
- SL solo se il CLOSE sfonda sl0 -/+ buffer*ATR14[j], fill a close[j].
- trend_max : None = filtro OFF (live attuale); 3.0 = candidato.
- hurst_mask : bool[i]=True -> salta l'ingresso (loss-guard live).
- """
- h, l, c = df["high"].values, df["low"].values, df["close"].values
- n = len(c)
- a = atr(df, 14)
- el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
- fee = fee_rt * lev
- out = []
- last = -1
- for e in ents:
- i, d = e["i"], e["d"]
- if i <= last or i + 1 >= n:
- continue
- if hurst_mask is not None and hurst_mask[i]:
- continue
- if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
- continue
- entry = c[i]
- tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
- exit_p = c[min(i + mb, n - 1)]
- j = min(i + mb, n - 1)
- for k in range(1, mb + 1):
- j = i + k
- if j >= n:
- exit_p = c[n - 1]
- break
- if mode == "orig":
- hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
- ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
- if hs:
- exit_p = sl0
- break
- if ht:
- exit_p = tp
- break
- if k == mb:
- exit_p = c[j]
- else: # exit16
- ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
- if ht:
- exit_p = tp
- break
- aj = a[j] if np.isfinite(a[j]) else 0.0
- confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
- (d == -1 and c[j] > sl0 + buffer * aj)
- if confirm:
- exit_p = c[j]
- break
- if k == mb:
- exit_p = c[j]
- ret = (exit_p - entry) / entry * d * lev - fee
- out.append((i, j, ret))
- last = j
- return out
-
-
-def equity_from_trades(df, trades):
- ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
- n = len(df)
- eq = np.full(n, INIT, dtype=float)
- cap = INIT
- for i, j, ret in sorted(trades, key=lambda t: t[1]):
- cap = max(cap + cap * POS * ret, 10.0)
- eq[j:] = cap
- s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
- return _norm(s)
-
-
-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 _dd(s):
- pk = s.cummax()
- return float(((pk - s) / pk).max() * 100)
def main():