8a2b065dd7
- _port06_gate_common.py: build_trades_variant/equity_from_trades/port_metrics/dd fattorizzati dai 3 gate exit16/trendmax/dip01 (-214 righe duplicate). Nessun copy-drift trovato; versione promossa = trendmax (superset con hurst_mask). Output dei 3 gate verificato BYTE-IDENTICO prima/dopo. dip_trades resta nel suo script (sibling deliberato long-only/orig_gap, non una copia). - drift_monitor.py: rolling-return per famiglia vs distribuzione storica propria (warn sotto p5; oggi: FADE 120g al p2). In crontab host giornaliero 07:15 UTC con report Telegram. Osservabilita', non filtro di trading. - daily_equity_bfill_impact.py: bug bfill _daily_equity QUANTIFICATO -> non materiale (OOS invariato per costruzione, FULL DD 3.46->3.67 col fix, nessun verdetto gate a rischio). Lasciato documentato in TODO, niente fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
210 lines
9.8 KiB
Python
210 lines
9.8 KiB
Python
"""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()
|