docs: statistiche per anno (trd/PnL%/maxDD) per strategia e mercato nel doc HTML

Ogni card ora include la tabella anno x mercato:
- fade MR01/02/07 (BTC+ETH) e DIP01: calcolate col PATH LIVE (EXIT-16 + trend 3.0),
  trades/PnL per anno di entry, DD per anno su equity compounding pos 0.15 + DD totale
- PR01: matrice anno x 5 coppie, cella = PnL% (n trade, DD anno) + riga TOT
  (pairs_sim espone ora anche yearly_n, modifica non-breaking)
- TR01/ROT02/TSM01: ret%/DD% per anno dall'equity canonica daily 2021+
- SH01: per anno dal walk-forward EXPANDING (regime validato e ora live)
Nota di convenzione su ogni tabella (leva 3x test vs 2x live, fee incluse) +
caveat: finestra canonica dal 2021, anni 2018-2020 mostrati per onesta' storica.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-07 17:32:00 +00:00
parent bc0ad3e86f
commit 2a97294d67
3 changed files with 200 additions and 21 deletions
+186 -9
View File
@@ -398,6 +398,172 @@ def chart_weights():
return b64(fig)
# ------------------------------------------------------- statistiche per anno
POS_T, LEV_T, FEE_T = 0.15, 3.0, 0.001 # convenzione TEST (canonica)
def yearly_stats(trades, ts):
"""trades [(i, j, ret_netto_leveraged)] -> ({anno: n/pnl/dd}, dd_totale).
n e PnL per anno di ENTRY (Σ rendimenti netti per trade ×100); DD per anno
sul path di equity compounding (pos 0.15), peak resettato a inizio anno."""
years: dict[int, dict] = {}
cap, peak, tot_peak, tot_dd = 1000.0, 1000.0, 1000.0, 0.0
cur = None
for i, j, ret in sorted(trades, key=lambda t: t[1]):
ey, xy = ts.iloc[i].year, ts.iloc[j].year
d = years.setdefault(ey, {"n": 0, "pnl": 0.0, "dd": 0.0})
d["n"] += 1
d["pnl"] += ret * 100
cap = max(cap + cap * POS_T * ret, 10.0)
if cur != xy:
cur, peak = xy, cap
peak = max(peak, cap)
tot_peak = max(tot_peak, cap)
tot_dd = max(tot_dd, (tot_peak - cap) / tot_peak * 100)
dx = years.setdefault(xy, {"n": 0, "pnl": 0.0, "dd": 0.0})
dx["dd"] = max(dx["dd"], (peak - cap) / peak * 100)
return years, tot_dd
def equity_yearly(eq: pd.Series):
"""Equity giornaliera -> {anno: pnl(ret% anno)/dd}, dd_totale (per i multi-asset)."""
out = {}
for y, g in eq.groupby(eq.index.year):
pk = g.cummax()
out[int(y)] = {"n": None, "pnl": (g.iloc[-1] / g.iloc[0] - 1) * 100,
"dd": float(((pk - g) / pk).max() * 100)}
pk = eq.cummax()
return out, float(((pk - eq) / pk).max() * 100)
def yearly_table(per_market: dict, note="") -> str:
"""{mercato: (years_dict, tot_dd)} -> tabella HTML anno × (trd, PnL%, DD%)."""
yrs = sorted({y for ym, _ in per_market.values() for y in ym})
head = "".join(f'<th colspan="3">{m}</th>' for m in per_market)
sub = "".join("<th>trd</th><th>PnL%</th><th>DD%</th>" for _ in per_market)
rows = []
for y in yrs:
cells = ""
for ym, _ in per_market.values():
d = ym.get(y)
if d:
n = "" if d["n"] is None else d["n"]
cells += f"<td>{n}</td><td>{d['pnl']:+.0f}</td><td>{d['dd']:.0f}</td>"
else:
cells += "<td>—</td><td>—</td><td>—</td>"
rows.append(f"<tr><td><b>{y}</b></td>{cells}</tr>")
cells = ""
for ym, tot_dd in per_market.values():
ns = [d["n"] for d in ym.values() if d["n"] is not None]
n = sum(ns) if ns else ""
pnl = sum(d["pnl"] for d in ym.values())
cells += f"<td><b>{n}</b></td><td><b>{pnl:+.0f}</b></td><td><b>{tot_dd:.0f}</b></td>"
rows.append(f'<tr style="background:#f0f2f5"><td><b>TOT</b></td>{cells}</tr>')
base = ("PnL = Σ rendimenti netti per trade (%, leva 3x, fee 0.10% RT, convenzione test; "
"il live gira a leva 2x); DD = max drawdown nell'anno (equity compounding pos 0.15); "
"riga TOT: DD dell'intero periodo. NB: la finestra canonica di valutazione del "
"portafoglio parte dal <b>2021</b> — gli anni 2018-2020 (regime microstrutturale "
"diverso, spesso negativi) sono mostrati per onestà storica.")
return (f'<table><tr><th rowspan="2">anno</th>{head}</tr><tr>{sub}</tr>'
+ "".join(rows) + f'</table><p class="sub">{note or base}</p>')
def stats_fades():
"""Per-anno delle 3 fade × BTC/ETH col PATH LIVE (EXIT-16 + trend 3.0)."""
from scripts.analysis.risk_management import strats_for
from scripts.analysis.trendmax_port06_impact import build_trades_variant
out = {}
for asset in ("BTC", "ETH"):
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
for nm, (fn, params) in strats_for(asset).items():
tr = build_trades_variant(fn(df, **params), df, mode="exit16", trend_max=3.0)
out.setdefault(nm, {})[asset] = yearly_stats(tr, ts)
return out
def stats_dip():
from scripts.analysis.dip01_exit16_impact import dip_entries, dip_trades
df = load_data("BTC", "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
tr = dip_trades(dip_entries(df), df, "exit16")
return {"BTC": yearly_stats(tr, ts)}
def stats_pairs():
"""Tabella compatta: per anno 'PnL% (n)' per coppia + righe TOT/DD."""
from scripts.analysis.pairs_research import pairs_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIRS_CFG
cols, data = [], {}
for a, b, p in PAIRS_CFG:
r = pairs_sim(a, b, **p)
tag = f"{a}/{b}"
cols.append(tag)
s = pd.Series(r["eq_v"], index=pd.to_datetime(r["eq_ts"], utc=True))
ydd = {int(y): float(((g.cummax() - g) / g.cummax()).max() * 100)
for y, g in s.groupby(s.index.year)}
data[tag] = dict(yearly=r["yearly"], n=r["yearly_n"], dd=r["dd"],
trades=r["trades"], ydd=ydd)
yrs = sorted({y for d in data.values() for y in d["yearly"]})
rows = []
for y in yrs:
cells = "".join(
(f"<td>{data[c]['yearly'][y]:+.0f} <span class='sub'>({data[c]['n'].get(y,0)}"
f", dd {data[c]['ydd'].get(y,0):.0f})</span></td>")
if y in data[c]["yearly"] else "<td>—</td>" for c in cols)
rows.append(f"<tr><td><b>{y}</b></td>{cells}</tr>")
tot = "".join(f"<td><b>{sum(data[c]['yearly'].values()):+.0f}</b> "
f"<span class='sub'>({data[c]['trades']}, dd {data[c]['dd']:.0f})</span></td>"
for c in cols)
rows.append(f'<tr style="background:#f0f2f5"><td><b>TOT</b></td>{tot}</tr>')
head = "".join(f"<th>{c}</th>" for c in cols)
return (f'<table><tr><th>anno</th>{head}</tr>' + "".join(rows) + "</table>"
'<p class="sub">cella: PnL% anno (n trade, max DD% anno) — Σ rendimenti netti '
"per trade, fee 0.20% RT/coppia (2 gambe), leva 3x convenzione test. NB: la "
"finestra canonica del portafoglio parte dal <b>2021</b>; gli anni precedenti "
"(spesso negativi) sono mostrati per onestà storica.</p>")
def stats_multi():
"""TR01/ROT02/TSM01: per-anno ret/DD dall'equity giornaliera canonica (2021+)."""
from scripts.analysis.combine_portfolio import IDX
from scripts.analysis.honest_improve2 import _tr_basket_daily, _rot_daily_equity
from scripts.analysis.tsmom_research import tsmom_sim
from scripts.analysis.report_families import daily_from
tr = _tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX)
rot = _rot_daily_equity(IDX)
t = tsmom_sim()
tsm = daily_from(t["eq_ts"], t["eq_v"])
note = ("PnL = ritorno % dell'anno dall'equity giornaliera canonica (2021-2026, leva 3x "
"convenzione test); DD = max drawdown nell'anno; trd non applicabile (ribilancio "
"giornaliero del book).")
return (yearly_table({"TR01 (paniere 5)": equity_yearly(tr)}, note),
yearly_table({"ROT02 (universo 8)": equity_yearly(rot)}, note),
yearly_table({"TSM01 (universo 8)": equity_yearly(tsm)}, note))
def stats_sh01():
"""SH01 per-anno dal walk-forward EXPANDING (il regime validato e ora live)."""
from scripts.analysis.shape_ml_research import ml_wf_entries
out = {}
for asset in ("BTC", "ETH"):
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
c = df["close"].values
ents = ml_wf_entries(df, W=24, H=12, model="logit", thresh=0.58)
tr, last = [], -1
for e in ents:
i, d, mb = e["i"], e["d"], e["max_bars"]
j = min(i + mb, len(c) - 1)
if i <= last or j <= i:
continue
ret = (c[j] - c[i]) / c[i] * d * LEV_T - FEE_T * LEV_T
tr.append((i, j, ret))
last = j
out[asset] = yearly_stats(tr, ts)
return out
# ----------------------------------------------------------------- HTML
CSS = """
body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f5f7;color:#222}
@@ -433,6 +599,14 @@ def main():
print("genero grafici (episodi reali)...")
panel = _daily_panel()
print("calcolo statistiche per anno (engine canonici/live-path)...")
st_fade = stats_fades()
st_dip = stats_dip()
t_pairs = stats_pairs()
t_tr, t_rot, t_tsm = stats_multi()
print(" SH01 walk-forward expanding (il piu' lento)...")
st_sh = stats_sh01()
g_w = chart_weights()
g_mr01 = chart_fade("scripts.strategies.MR01_bollinger_fade", "BTC",
dict(trend_max=3.0, ema_long=200), mr01_bands,
@@ -460,17 +634,20 @@ def main():
c_mr01 = card("MR01 — Bollinger Fade (BTC, ETH)", B("fade", "FADE") + real, """
<p>Quando il <b>close chiude fuori dalla banda</b> ±2.5σ attorno alla SMA50, entra CONTRO il
movimento (short sopra, long sotto). TP alla media (il prezzo "torna a casa"), SL a 2·ATR,
time-limit 24 barre. Edge OOS validato: BTC +201% / ETH +1238% (fee incluse).</p>""", g_mr01)
time-limit 24 barre. Edge OOS validato: BTC +201% / ETH +1238% (fee incluse).</p>""", g_mr01,
yearly_table({"BTC": st_fade["MR01"]["BTC"], "ETH": st_fade["MR01"]["ETH"]}))
c_mr02 = card("MR02 — Donchian Fade (BTC, ETH)", B("fade", "FADE") + real, """
<p>Fada la <b>rottura degli estremi del canale</b> Donchian a 20 barre (max/min recenti):
short sulla rottura del massimo, long sulla rottura del minimo. TP al centro del canale.
Stessa tesi di MR01 con trigger diverso → si combinano bene.</p>""", g_mr02)
Stessa tesi di MR01 con trigger diverso → si combinano bene.</p>""", g_mr02,
yearly_table({"BTC": st_fade["MR02"]["BTC"], "ETH": st_fade["MR02"]["ETH"]}))
c_mr07 = card("MR07 — Return Reversal (BTC, ETH)", B("fade", "FADE") + real, """
<p>Guarda il <b>rendimento della singola barra</b>: se lo z-score supera ±3.5 (movimento
estremo in un'ora), fada il movimento. Exit in multipli di ATR. È la fade più selettiva
(esposizione ~8% del tempo).</p>""", g_mr07)
(esposizione ~8% del tempo).</p>""", g_mr07,
yearly_table({"BTC": st_fade["MR07"]["BTC"], "ETH": st_fade["MR07"]["ETH"]}))
c_e16 = card("EXIT-16 — perché lo stop scatta solo sul CLOSE", B("fade", "MECCANISMO COMUNE"), """
<p>Scoperta chiave della ricerca exit (34 agenti, 23 famiglie): <b>gli stop intrabar da wick
@@ -482,17 +659,17 @@ portafoglio: OOS Sharpe 8.82→10.06. Esteso oggi anche a DIP01 (grid 36/36).</p
c_dip = card("DIP01 — Dip Buy (BTC)", B("honest", "HONEST") + real, """
<p>Compra il <b>dip</b>: quando lo z-score del prezzo incrocia sotto 2.5 (sell-off rapido),
entra long. TP alla SMA50, EXIT-16 sul SL, max 24 barre. È l'unico sleeve BTC con round-trip
reali su Deribit testnet (TP limit resting + disaster-stop a 30% sul book).</p>""", g_dip)
reali su Deribit testnet (TP limit resting + disaster-stop a 30% sul book).</p>""", g_dip, yearly_table(st_dip))
c_tr = card("TR01 — Basket Trend (4h)", B("honest", "HONEST") + sim, """
<p><b>Trend-following difensivo</b>: long quando EMA20>EMA100 sulle 4 ore, flat altrimenti,
su un paniere equal-weight di 5 asset (BNB, BTC, DOGE, SOL, XRP). Cattura i trend lunghi che
le fade per costruzione non prendono. Valuta solo barre 4h COMPLETE.</p>""", g_tr)
le fade per costruzione non prendono. Valuta solo barre 4h COMPLETE.</p>""", g_tr, t_tr)
c_rot = card("ROT02 — Dual Momentum (1d)", B("honest", "HONEST") + sim, """
<p><b>Rotazione</b>: ogni giorno ordina 8 crypto per momentum a 60 giorni e tiene le top-3
(solo se positive), gross 0.45. Gate di regime: tutto cash se BTC&lt;SMA100. Diversificare su 3
asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""", g_rot)
asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""", g_rot, t_rot)
c_pr = card("PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL)",
B("pairs", "PAIRS") + sim, """
@@ -500,13 +677,13 @@ asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""
(|z| del log-ratio ≥ 2), compra la gamba debole e shorta la forte; chiude quando il rapporto
rientra (|z| ≤ 0.75) o dopo 72 barre. Config <b>universale</b> per tutte le coppie (niente tuning
per-coppia = anti-overfit). Correlazione col mercato ~0.05: rende anche quando il mercato è fermo.
Fee su 2 gambe. Senza stop per design → position size ridotto a 0.20 (esposizione ≈ validato).</p>""", g_pr)
Fee su 2 gambe. Senza stop per design → position size ridotto a 0.20 (esposizione ≈ validato).</p>""", g_pr, t_pairs)
c_tsm = card("TSM01 — TSMOM (1d)", B("tsm", "TSM") + sim, """
<p>Long sugli asset con <b>consenso pieno</b> di momentum su 3 orizzonti (3/6/12 mesi),
gross 0.30, cash totale se BTC&lt;SMA100. Mai un anno negativo nel backtest. Non è un motore di
ritorno: è il <b>diversificatore</b> che lavora nei regimi in cui le fade soffrono.
Attualmente flat by-design (risk-off).</p>""", g_tsm)
Attualmente flat by-design (risk-off).</p>""", g_tsm, t_tsm)
c_sh = card("SH01 — Shape-ML (BTC, ETH)", B("shape", "SHAPE") + sim, """
<p>Una <b>LogisticRegression</b> legge 17 feature della <i>forma</i> delle ultime 24 barre e
@@ -515,7 +692,7 @@ orizzonte. Training <b>walk-forward causale</b> (mai dati futuri). Win-rate ~50%
nell'asimmetria, non nella frequenza. <b>Senza stop-loss by design</b> (ogni stop testato rompe
l'edge): la coda si gestisce dimezzando il peso della famiglia (cap 5.88%).</p>
<p class='sub'>Fix di oggi (punto-10): il training live usa la storia COMPLETA dal parquet
locale (il regime corto a 365g non era robusto: trade-rate 22% vs 10% validato).</p>""", g_sh)
locale (il regime corto a 365g non era robusto: trade-rate 22% vs 10% validato).</p>""", g_sh, yearly_table(st_sh))
html = f"""<!doctype html><html lang="it"><head><meta charset="utf-8">
<title>PythagorasGoal — Strategie attive PORT06</title><style>{CSS}</style></head>
+4 -2
View File
@@ -51,7 +51,7 @@ def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
split = int(N * split_frac)
fee = 2 * fee_rt * lev # 2 gambe
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = 0; rets = []; yearly = {}
trades = wins = 0; rets = []; yearly = {}; yearly_n = {}
eq_ts: list = []; eq_v: list = []
for i in range(n + 1, N - 1):
if i < split or np.isnan(z[i]) or dr[i] > jump_max:
@@ -81,6 +81,7 @@ def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
yearly_n[ts.iloc[i].year] = yearly_n.get(ts.iloc[i].year, 0) + 1
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(BARS_YEAR / np.mean([max_bars])) ) if len(rets) > 1 and np.std(rets) > 0 else 0.0
# Sharpe annualizzato sul tempo reale: usa rendimenti per-trade scalati alla frequenza media
@@ -90,7 +91,8 @@ def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
ret_tot = (cap / 1000 - 1) * 100
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot, cagr=cagr,
dd=dd * 100, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v)
dd=dd * 100, sharpe=sharpe, yearly=yearly, yearly_n=yearly_n,
eq_ts=eq_ts, eq_v=eq_v)
def check_no_lookahead():