diff --git a/docs/report/strategie_attive.html b/docs/report/strategie_attive.html
new file mode 100644
index 0000000..8acaaa9
--- /dev/null
+++ b/docs/report/strategie_attive.html
@@ -0,0 +1,101 @@
+
+PythagorasGoal — Strategie attive PORT06
+
+
PythagorasGoal — Strategie attive
+
Portafoglio live PORT06 (17 sleeve, capitale pool €2.000, leva 2x) ·
+v1.1.9 · generato 2026-06-07 16:55 UTC · backtest canonico: FULL Sharpe 6.61 / DD 3.58% — OOS Sharpe 8.77 / DD 1.34%
+
+
Tre famiglie principali quasi scorrelate fra loro (fade↔honest ~0.05, pairs ~0.02-0.09,
+shape ~0.08): la diversificazione è la leva anti-drawdown. Pesi equal con tetti per famiglia,
+ribilancio giornaliero su capitale pool condiviso. Fee Deribit 0.10% round-trip incluse ovunque;
+ogni meccanismo live è passato da un gate out-of-sample a livello di portafoglio.
+

+
+
FADE — mean-reversion intraday 1h (6 sleeve × 6.47%)
+
Tesi della famiglia: sui perpetui crypto l'edge è la reversione:
+i movimenti estremi rientrano (i breakout falliscono — l'intera famiglia squeeze-breakout è stata
+scartata come artefatto di look-ahead). Le fade vendono l'eccesso e comprano il panico, con tre
+protezioni comuni: filtro trend (salta il segnale se il prezzo è oltre 3·ATR dalla EMA200 —
+non si fada un crollo/parabolica), EXIT-16 (stop solo sul close confermato, vedi sotto) e
+min_tp_frac (salta i micro-trade col TP entro le fee).
+
MR01 — Bollinger Fade (BTC, ETH)
FADEESECUZIONE REALE (testnet)
+
Quando il close chiude fuori dalla banda ±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).

+
MR02 — Donchian Fade (BTC, ETH)
FADEESECUZIONE REALE (testnet)
+
Fada la rottura degli estremi del canale 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.

+
MR07 — Return Reversal (BTC, ETH)
FADEESECUZIONE REALE (testnet)
+
Guarda il rendimento della singola barra: 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).

+
EXIT-16 — perché lo stop scatta solo sul CLOSE
MECCANISMO COMUNE
+
Scoperta chiave della ricerca exit (34 agenti, 23 famiglie): gli stop intrabar da wick
+sono falsi negativi — l'overshoot che buca lo stop e rientra è esattamente il movimento che
+la fade sta comprando. Con EXIT-16 lo SL intrabar è disattivato: si esce solo se il close
+della barra completata sfonda il livello di 0.5·ATR. Il TP intrabar resta. Impatto sul
+portafoglio: OOS Sharpe 8.82→10.06. Esteso oggi anche a DIP01 (grid 36/36).

+
HONEST — long-only multi-regime (3 sleeve × 6.47%)
+
DIP01 — Dip Buy (BTC)
HONESTESECUZIONE REALE (testnet)
+
Compra il dip: 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).

+
TR01 — Basket Trend (4h)
HONESTSIMULATO
+
Trend-following difensivo: 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.

+
ROT02 — Dual Momentum (1d)
HONESTSIMULATO
+
Rotazione: 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<SMA100. Diversificare su 3
+asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.

+
PAIRS — spread reversion market-neutral (5 coppie × 5.88%, famiglia ≤33%)
+
PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL)
PAIRSSIMULATO
+
Market-neutral: quando il rapporto fra due asset si allontana troppo dalla sua media
+(|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 universale 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).

+
TSM — trend-following multi-orizzonte (1 sleeve × 6.47%)
+
TSM01 — TSMOM (1d)
TSMSIMULATO
+
Long sugli asset con consenso pieno di momentum su 3 orizzonti (3/6/12 mesi),
+gross 0.30, cash totale se BTC<SMA100. Mai un anno negativo nel backtest. Non è un motore di
+ritorno: è il diversificatore che lavora nei regimi in cui le fade soffrono.
+Attualmente flat by-design (risk-off).

+
SHAPE — ML morfologico (2 sleeve × 2.94%, famiglia ≤5.88%)
+
SH01 — Shape-ML (BTC, ETH)
SHAPESIMULATO
+
Una LogisticRegression legge 17 feature della forma delle ultime 24 barre e
+predice il segno del rendimento a 12 barre; entra solo se la probabilità supera 0.58, esce a
+orizzonte. Training walk-forward causale (mai dati futuri). Win-rate ~50%: l'edge è
+nell'asimmetria, non nella frequenza. Senza stop-loss by design (ogni stop testato rompe
+l'edge): la coda si gestisce dimezzando il peso della famiglia (cap 5.88%).
+
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).

+
+
Metodologia
+
+- Fee sempre incluse: 0.10% round-trip taker Deribit (misurate reali = assunte). Molte operazioni = morte per fee: ogni strategia regge lo stress a fee doppie.
+- Niente look-ahead: direzione e prezzo decisi solo con dati fino al close corrente; barre in formazione escluse (lezione EXIT-16). La famiglia squeeze (accuratezze 76-82%) è stata scartata proprio per questo artefatto.
+- Gate out-of-sample: nessun meccanismo va in produzione senza migliorare (o non degradare) il portafoglio FULL e OOS — robusto individualmente ≠ migliora PORT06.
+- Esecuzione reale shadow: i 7 sleeve single-leg eseguono ordini reali su Deribit testnet accanto al sim (TP limit al livello, disaster-stop −30% on-book, fee reali verificate dai trade); divergenze sim/reale ≥100bps alertate su Telegram.
+
+
Generato da scripts/analysis/make_strategy_doc.py — grafici da episodi reali sui dati parquet locali.
+
\ No newline at end of file
diff --git a/scripts/analysis/make_strategy_doc.py b/scripts/analysis/make_strategy_doc.py
new file mode 100644
index 0000000..1690bd3
--- /dev/null
+++ b/scripts/analysis/make_strategy_doc.py
@@ -0,0 +1,570 @@
+"""Genera docs/report/strategie_attive.html — documento autocontenuto (PNG base64)
+con tutte le strategie ATTIVE di PORT06: descrizione, config live e grafici
+esplicativi costruiti su EPISODI REALI di segnale (dati parquet locali).
+
+ uv run python scripts/analysis/make_strategy_doc.py
+"""
+from __future__ import annotations
+
+import base64
+import io
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+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.portfolios._defs import PORTFOLIOS
+from src.portfolio import weighting as W
+
+OUT = PROJECT_ROOT / "docs" / "report" / "strategie_attive.html"
+plt.rcParams.update({"font.size": 9.5, "axes.grid": True, "grid.alpha": 0.25,
+ "figure.facecolor": "white", "axes.facecolor": "#fbfbfd"})
+
+C_UP, C_DN = "#2e9e6b", "#d64545"
+C_ENTRY, C_TP, C_SL = "#1f6fd6", "#2e9e6b", "#d64545"
+
+
+# ----------------------------------------------------------------- helpers
+def b64(fig) -> str:
+ buf = io.BytesIO()
+ fig.savefig(buf, format="png", dpi=115, bbox_inches="tight")
+ plt.close(fig)
+ return base64.b64encode(buf.getvalue()).decode()
+
+
+def candles(ax, d):
+ t = mdates.date2num(pd.to_datetime(d["timestamp"], unit="ms", utc=True))
+ w = (t[1] - t[0]) * 0.65 if len(t) > 1 else 0.02
+ for k in range(len(d)):
+ o, h, l, c = (d[x].iloc[k] for x in ("open", "high", "low", "close"))
+ col = C_UP if c >= o else C_DN
+ ax.plot([t[k], t[k]], [l, h], color=col, lw=0.7, zorder=2)
+ ax.add_patch(plt.Rectangle((t[k] - w / 2, min(o, c)), w, abs(c - o) or 1e-9,
+ facecolor=col, edgecolor=col, zorder=3))
+ ax.xaxis_date()
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%d %b\n%H:%M"))
+ return t
+
+
+def atr(df, n=14):
+ h, l, c = df["high"].values, df["low"].values, df["close"].values
+ pc = np.roll(c, 1); pc[0] = c[0]
+ tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
+ return pd.Series(tr).rolling(n).mean().values
+
+
+def find_winner(sigs, df, since_idx):
+ """Primo segnale (recente) il cui TP viene toccato entro max_bars."""
+ h, l = df["high"].values, df["low"].values
+ for s in reversed(sigs):
+ if s.idx < since_idx:
+ break
+ tp = s.metadata.get("tp"); mb = s.metadata.get("max_bars", 24)
+ if not tp:
+ continue
+ for j in range(s.idx + 1, min(s.idx + mb + 1, len(df))):
+ hit = h[j] >= tp if s.direction == 1 else l[j] <= tp
+ if hit:
+ return s, j
+ return None, None
+
+
+def load_strategy(module):
+ import importlib
+ m = importlib.import_module(module)
+ return next(v() for k, v in vars(m).items()
+ if isinstance(v, type) and hasattr(v, "generate_signals")
+ and getattr(v, "__module__", "") == m.__name__)
+
+
+def mark_trade(ax, t, d0, s, jx, tp, sl, win_lo):
+ ei = s.idx - win_lo
+ ax.axvline(t[ei], color=C_ENTRY, lw=1, ls=":")
+ ax.annotate(f"ENTRY {'LONG' if s.direction==1 else 'SHORT'}\n@{s.entry_price:.5g}",
+ (t[ei], s.entry_price), xytext=(-65, 25 if s.direction == 1 else -35),
+ textcoords="offset points", color=C_ENTRY, fontweight="bold",
+ arrowprops=dict(arrowstyle="->", color=C_ENTRY))
+ ax.axhline(tp, color=C_TP, lw=1.2, ls="--")
+ ax.annotate("TP", (t[-1], tp), color=C_TP, fontweight="bold",
+ xytext=(4, 0), textcoords="offset points")
+ if sl:
+ ax.axhline(sl, color=C_SL, lw=1.2, ls="--")
+ ax.annotate("SL", (t[-1], sl), color=C_SL, fontweight="bold",
+ xytext=(4, 0), textcoords="offset points")
+ if jx is not None:
+ xi = jx - win_lo
+ ax.annotate("EXIT take-profit", (t[xi], tp), xytext=(10, -28),
+ textcoords="offset points", color=C_TP, fontweight="bold",
+ arrowprops=dict(arrowstyle="->", color=C_TP))
+
+
+# ----------------------------------------------------------------- grafici fade
+def chart_fade(module, asset, params, band_fn, title, panel_fn=None):
+ df = load_data(asset, "1h")
+ ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
+ strat = load_strategy(module)
+ sigs = strat.generate_signals(df, ts, **params)
+ since = int(len(df) * 0.85)
+ s, j = find_winner(sigs, df, since)
+ if s is None:
+ s, j = find_winner(sigs, df, int(len(df) * 0.5))
+ lo, hi = s.idx - 36, min((j or s.idx + 24) + 10, len(df) - 1)
+ d = df.iloc[lo:hi].reset_index(drop=True)
+
+ if panel_fn:
+ fig, (ax, ax2) = plt.subplots(2, 1, figsize=(8.6, 4.6), sharex=True,
+ height_ratios=[2.2, 1])
+ else:
+ fig, ax = plt.subplots(figsize=(8.6, 3.6))
+ ax2 = None
+ t = candles(ax, d)
+ band_fn(ax, df, lo, hi, t)
+ mark_trade(ax, t, d, s, j, s.metadata["tp"], s.metadata.get("sl"), lo)
+ ax.set_title(title, loc="left", fontweight="bold")
+ if panel_fn:
+ panel_fn(ax2, df, lo, hi, t, s)
+ return b64(fig)
+
+
+def mr01_bands(ax, df, lo, hi, t):
+ c = pd.Series(df["close"].values)
+ ma = c.rolling(50).mean().values[lo:hi]
+ sd = c.rolling(50).std().values[lo:hi]
+ ax.plot(t, ma, color="#444", lw=1, label="SMA50 (= TP)")
+ ax.plot(t, ma + 2.5 * sd, color="#9467bd", lw=1, ls="-.", label="banda ±2.5σ")
+ ax.plot(t, ma - 2.5 * sd, color="#9467bd", lw=1, ls="-.")
+ ax.legend(loc="upper left", fontsize=8)
+
+
+def mr02_bands(ax, df, lo, hi, t):
+ hh = pd.Series(df["high"].values).rolling(20).max().shift(1).values[lo:hi]
+ ll = pd.Series(df["low"].values).rolling(20).min().shift(1).values[lo:hi]
+ ax.plot(t, hh, color="#9467bd", lw=1, ls="-.", label="canale Donchian 20 (H/L)")
+ ax.plot(t, ll, color="#9467bd", lw=1, ls="-.")
+ ax.plot(t, (hh + ll) / 2, color="#444", lw=1, label="centro canale (= TP)")
+ ax.legend(loc="upper left", fontsize=8)
+
+
+def mr07_panel(ax2, df, lo, hi, t, s):
+ c = df["close"].values
+ r = pd.Series(c).pct_change()
+ z = (r - r.rolling(50).mean()) / r.rolling(50).std()
+ ax2.plot(t, z.values[lo:hi], color="#1f6fd6", lw=1)
+ ax2.axhline(3.5, color=C_SL, ls="--", lw=1)
+ ax2.axhline(-3.5, color=C_SL, ls="--", lw=1)
+ ax2.set_ylabel("z rendimento")
+ ax2.annotate("|z| ≥ 3.5 → fade", (t[s.idx - lo], 3.5), xytext=(8, 8),
+ textcoords="offset points", color=C_SL, fontsize=8)
+
+
+def chart_dip01():
+ df = load_data("BTC", "1h")
+ ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
+ strat = load_strategy("scripts.strategies.DIP01_dip_buy")
+ sigs = strat.generate_signals(df, ts)
+ s, j = find_winner(sigs, df, int(len(df) * 0.85))
+ lo, hi = s.idx - 36, min((j or s.idx + 24) + 10, len(df) - 1)
+ d = df.iloc[lo:hi].reset_index(drop=True)
+ fig, (ax, ax2) = plt.subplots(2, 1, figsize=(8.6, 4.6), sharex=True,
+ height_ratios=[2.2, 1])
+ t = candles(ax, d)
+ c = pd.Series(df["close"].values)
+ ma = c.rolling(50).mean().values[lo:hi]
+ ax.plot(t, ma, color="#444", lw=1, label="SMA50 (= TP)")
+ ax.legend(loc="upper left", fontsize=8)
+ mark_trade(ax, t, d, s, j, s.metadata["tp"], s.metadata.get("sl"), lo)
+ ax.set_title("DIP01 — dip-buy sullo z-score (episodio reale BTC)", loc="left",
+ fontweight="bold")
+ sd = c.rolling(50).std()
+ z = ((c - c.rolling(50).mean()) / sd).values[lo:hi]
+ ax2.plot(t, z, color="#1f6fd6", lw=1)
+ ax2.axhline(-2.5, color=C_SL, ls="--", lw=1)
+ ax2.set_ylabel("z prezzo")
+ ax2.annotate("z incrocia sotto −2.5 → BUY", (t[s.idx - lo], -2.5), xytext=(8, -14),
+ textcoords="offset points", color=C_SL, fontsize=8)
+ return b64(fig)
+
+
+def chart_exit16():
+ """Episodio reale: il wick BUCA lo SL ma il close non conferma -> niente stop,
+ il trade va a TP. Cerca nelle MR01 ETH (dove EXIT-16 e' nato)."""
+ df = load_data("ETH", "1h")
+ ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
+ strat = load_strategy("scripts.strategies.MR01_bollinger_fade")
+ sigs = strat.generate_signals(df, ts, trend_max=3.0, ema_long=200)
+ h, l, c = df["high"].values, df["low"].values, df["close"].values
+ a = atr(df, 14)
+ ep = None
+ for s in reversed(sigs):
+ tp, sl, mb = s.metadata["tp"], s.metadata["sl"], s.metadata["max_bars"]
+ if s.direction != 1:
+ continue
+ wick = None
+ for j in range(s.idx + 1, min(s.idx + mb + 1, len(df))):
+ if h[j] >= tp: # TP raggiunto
+ if wick is not None:
+ ep = (s, wick, j)
+ break
+ if l[j] <= sl and c[j] >= sl - 0.5 * a[j]:
+ wick = j # wick sotto SL ma close non conferma
+ elif c[j] < sl - 0.5 * a[j]:
+ break # stop vero
+ if ep:
+ break
+ s, wick, j = ep
+ lo, hi = s.idx - 20, min(j + 8, len(df) - 1)
+ d = df.iloc[lo:hi].reset_index(drop=True)
+ fig, ax = plt.subplots(figsize=(8.6, 3.8))
+ t = candles(ax, d)
+ mark_trade(ax, t, d, s, j, s.metadata["tp"], s.metadata["sl"], lo)
+ buf = s.metadata["sl"] - 0.5 * a[wick]
+ ax.axhline(buf, color="#e08c1a", lw=1.1, ls=":")
+ ax.annotate("conferma: SL − 0.5·ATR", (t[-1], buf), color="#e08c1a",
+ xytext=(4, 0), textcoords="offset points", fontsize=8)
+ ax.annotate("il WICK buca lo SL\nma il CLOSE non conferma\n→ NIENTE stop (EXIT-16)",
+ (t[wick - lo], l[wick]), xytext=(15, -52), textcoords="offset points",
+ color=C_SL, fontweight="bold",
+ arrowprops=dict(arrowstyle="->", color=C_SL))
+ ax.set_title("EXIT-16 — lo stop scatta solo sul CLOSE confermato (episodio reale ETH)",
+ loc="left", fontweight="bold")
+ return b64(fig)
+
+
+# ------------------------------------------------------------ honest / pairs / tsm
+def chart_tr01():
+ df = load_data("BTC", "1h")
+ d = df.set_index(pd.to_datetime(df["timestamp"], unit="ms", utc=True))
+ d4 = d.resample("4h").agg({"open": "first", "high": "max", "low": "min",
+ "close": "last"}).dropna().iloc[-1100:]
+ ef = d4["close"].ewm(span=20, adjust=False).mean()
+ es = d4["close"].ewm(span=100, adjust=False).mean()
+ long = ef > es
+ fig, ax = plt.subplots(figsize=(8.6, 3.4))
+ ax.plot(d4.index, d4["close"], color="#333", lw=0.9, label="BTC 4h")
+ ax.plot(d4.index, ef, color=C_TP, lw=1.1, label="EMA20")
+ ax.plot(d4.index, es, color="#9467bd", lw=1.1, label="EMA100")
+ ax.fill_between(d4.index, d4["close"].min(), d4["close"].max(), where=long,
+ alpha=0.10, color=C_TP, label="LONG (EMA20>EMA100)")
+ ax.legend(loc="upper left", fontsize=8, ncol=2)
+ ax.set_title("TR01 — trend EMA20/100 4h, long/flat (qui BTC; live: paniere di 5 equal-weight)",
+ loc="left", fontweight="bold")
+ return b64(fig)
+
+
+def _daily_panel():
+ out = {}
+ for a in ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]:
+ df = load_data(a, "1h")
+ s = pd.Series(df["close"].values,
+ index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
+ out[a] = s.resample("1D").last().dropna()
+ return pd.DataFrame(out).dropna()
+
+
+def chart_rot02(panel):
+ btc = panel["BTC"]
+ sma = btc.rolling(100).mean()
+ mom = panel.pct_change(60).iloc[-1] * 100
+ order = mom.sort_values(ascending=False)
+ fig, (ax, ax2) = plt.subplots(1, 2, figsize=(8.6, 3.4), width_ratios=[1.7, 1])
+ view = slice(-540, None)
+ ax.plot(btc.index[view], btc.values[view], color="#333", lw=0.9, label="BTC 1d")
+ ax.plot(sma.index[view], sma.values[view], color="#9467bd", lw=1.1, label="SMA100")
+ on = (btc > sma).values[view]
+ ax.fill_between(btc.index[view], btc.values[view].min(), btc.values[view].max(),
+ where=on, alpha=0.10, color=C_TP, label="risk-ON")
+ ax.legend(loc="upper left", fontsize=8)
+ ax.set_title("ROT02 — gate di regime (BTC>SMA100)", loc="left", fontweight="bold")
+ cols = [C_TP if (k < 3 and v > 0) else "#bbb" for k, v in enumerate(order.values)]
+ ax2.bar(order.index, order.values, color=cols)
+ ax2.set_title("momentum 60g: top-3 in book", loc="left", fontweight="bold")
+ ax2.tick_params(axis="x", rotation=60)
+ ax2.set_ylabel("%")
+ return b64(fig)
+
+
+def chart_pr01():
+ a = load_data("ETH", "1h"); b = load_data("BTC", "1h")
+ m = a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge(
+ b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp")
+ m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
+ m = m.iloc[-24 * 200:]
+ r = np.log(m["ca"] / m["cb"])
+ z = (r - r.rolling(50).mean()) / r.rolling(50).std()
+ fig, (ax, ax2) = plt.subplots(2, 1, figsize=(8.6, 4.6), sharex=True,
+ height_ratios=[1, 1.4])
+ na = m["ca"] / m["ca"].iloc[0]; nb = m["cb"] / m["cb"].iloc[0]
+ ax.plot(m["dt"], na, lw=0.9, label="ETH (gamba A)", color="#1f6fd6")
+ ax.plot(m["dt"], nb, lw=0.9, label="BTC (gamba B)", color="#e08c1a")
+ ax.legend(loc="upper left", fontsize=8); ax.set_ylabel("prezzi normalizzati")
+ ax.set_title("PR01 — spread reversion ETH/BTC (market-neutral, 2 gambe)",
+ loc="left", fontweight="bold")
+ ax2.plot(m["dt"], z, color="#333", lw=0.8)
+ for y, col, lab in ((2, C_SL, "entry |z|≥2"), (-2, C_SL, None),
+ (0.75, C_TP, "exit |z|≤0.75"), (-0.75, C_TP, None)):
+ ax2.axhline(y, color=col, ls="--", lw=1)
+ if lab:
+ ax2.annotate(lab, (m["dt"].iloc[-1], y), xytext=(4, 2),
+ textcoords="offset points", color=col, fontsize=8)
+ ent = (z.shift(1).abs() < 2) & (z.abs() >= 2)
+ ax2.plot(m["dt"][ent], z[ent], "v", color=C_SL, ms=6)
+ ax2.set_ylabel("z-score log-ratio")
+ return b64(fig)
+
+
+def chart_tsm01(panel):
+ P = panel.iloc[-380:]
+ signs = pd.DataFrame({h: np.sign(P.iloc[-1] / P.iloc[-1 - h] - 1)
+ for h in (63, 126, 252)}).T
+ fig, (ax, ax2) = plt.subplots(1, 2, figsize=(8.6, 3.2), width_ratios=[1.6, 1])
+ btc = panel["BTC"].iloc[-380:]
+ sma = panel["BTC"].rolling(100).mean().iloc[-380:]
+ ax.plot(btc.index, btc.values, color="#333", lw=0.9, label="BTC 1d")
+ ax.plot(sma.index, sma.values, color="#9467bd", lw=1.1, label="SMA100")
+ off = (btc <= sma).values
+ ax.fill_between(btc.index, btc.values.min(), btc.values.max(), where=off,
+ alpha=0.12, color=C_SL, label="risk-OFF → cash")
+ ax.legend(loc="upper left", fontsize=8)
+ ax.set_title("TSM01 — gate risk-off", loc="left", fontweight="bold")
+ im = ax2.imshow(signs.values, cmap="RdYlGn", vmin=-1, vmax=1, aspect="auto")
+ ax2.set_xticks(range(len(signs.columns)), signs.columns, rotation=60, fontsize=8)
+ ax2.set_yticks(range(3), ["3 mesi", "6 mesi", "12 mesi"], fontsize=8)
+ ax2.set_title("consenso momentum (verde=+)", loc="left", fontweight="bold")
+ ax2.grid(False)
+ return b64(fig)
+
+
+def chart_sh01():
+ df = load_data("BTC", "1h")
+ d = df.iloc[-24:].reset_index(drop=True)
+ fig, (ax, ax2) = plt.subplots(1, 2, figsize=(8.6, 3.6), width_ratios=[1, 1.4])
+ t = candles(ax, d)
+ ax.xaxis.set_major_locator(mdates.HourLocator(interval=8))
+ ax.set_title("la 'forma': 24 barre → 17 feature", loc="left",
+ fontweight="bold", fontsize=9)
+ ax.annotate("body/shadow, rendimenti,\npendenza, curvatura,\npos. max/min, RSI, estensione",
+ (0.04, 0.04), xycoords="axes fraction", fontsize=8.5,
+ bbox=dict(boxstyle="round", fc="#fffbe8", ec="#e0c25a"))
+ # schema walk-forward
+ ax2.set_xlim(0, 10); ax2.set_ylim(0, 3.4); ax2.axis("off")
+ ax2.add_patch(plt.Rectangle((0.2, 1.9), 7.0, 0.9, fc="#dbe9fb", ec="#1f6fd6"))
+ ax2.text(3.7, 2.35, "TRAIN: tutta la storia con esito noto (expanding)",
+ ha="center", fontsize=9, color="#1f4f96")
+ ax2.add_patch(plt.Rectangle((7.4, 1.9), 2.2, 0.9, fc="#d9f2e4", ec=C_TP))
+ ax2.text(8.5, 2.35, "PREDICT\nultimo blocco", ha="center", va="center",
+ fontsize=8.5, color="#1c6b46")
+ ax2.annotate("", xy=(8.3, 1.75), xytext=(4.0, 1.75),
+ arrowprops=dict(arrowstyle="->", color="#555"))
+ ax2.text(6.1, 1.5, "LogisticRegression: P(rendimento a 12 barre > 0)",
+ ha="center", fontsize=8.5, color="#555")
+ ax2.text(0.2, 0.85, "se proba ≥ 0.58 → entra a close, esce dopo H=12 barre\n"
+ "(nessun TP/SL: 11 famiglie di stop testate, 0 sopravvissute →\n"
+ " la coda si gestisce col CAP di famiglia al 5.88%)",
+ fontsize=8.5, va="top",
+ bbox=dict(boxstyle="round", fc="#fff", ec="#ccc"))
+ ax2.set_title("walk-forward causale", loc="left", fontweight="bold", fontsize=9)
+ ax2.text(0.2, 3.25, "live: storia full dal parquet, fit solo dell'ultimo blocco",
+ fontsize=8, color="#666", va="top")
+ fig.tight_layout()
+ return b64(fig)
+
+
+def chart_weights():
+ p = PORTFOLIOS["PORT06"]
+ ids = p.sleeve_ids
+ w = W.weight_vector("cap", ids, None, caps=p.caps)
+ fam = {i: W.family_of(i) for i in ids}
+ colors = {"FADE": "#1f6fd6", "HONEST": "#e08c1a", "PAIRS": "#9467bd",
+ "TSM": "#5ab4ac", "SHAPE": "#d64545"}
+ order = sorted(ids, key=lambda i: (fam[i], i))
+ fig, ax = plt.subplots(figsize=(8.6, 2.9))
+ ax.bar(order, [w[i] * 100 for i in order], color=[colors[fam[i]] for i in order])
+ ax.set_ylabel("peso %")
+ ax.tick_params(axis="x", rotation=60)
+ handles = [plt.Rectangle((0, 0), 1, 1, fc=c) for c in colors.values()]
+ ax.legend(handles, colors.keys(), fontsize=8, ncol=5, loc="upper right")
+ ax.set_title("PORT06 — pesi cap-weighting (tetti: PAIRS 33%, SHAPE 5.88%), ribilancio 1D",
+ loc="left", fontweight="bold")
+ return b64(fig)
+
+
+# ----------------------------------------------------------------- HTML
+CSS = """
+body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f5f7;color:#222}
+.wrap{max-width:960px;margin:0 auto;padding:24px 16px 60px}
+h1{font-size:26px;margin:8px 0 2px} h2{font-size:20px;border-bottom:2px solid #ddd;
+padding-bottom:6px;margin-top:38px} .sub{color:#666;font-size:13px}
+.card{background:#fff;border:1px solid #e3e5e8;border-radius:10px;padding:18px 20px;margin:14px 0;
+box-shadow:0 1px 3px rgba(0,0,0,.05)}
+.badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:10px;
+margin-right:6px;color:#fff}
+.b-fade{background:#1f6fd6}.b-honest{background:#e08c1a}.b-pairs{background:#9467bd}
+.b-tsm{background:#5ab4ac}.b-shape{background:#d64545}.b-real{background:#2e9e6b}
+.b-sim{background:#8a8f98}
+img{max-width:100%;border:1px solid #eee;border-radius:6px;margin-top:10px}
+table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px}
+td,th{border:1px solid #e3e5e8;padding:5px 9px;text-align:left}
+th{background:#f0f2f5} code{background:#eef1f4;padding:1px 5px;border-radius:4px;font-size:12px}
+.note{background:#fffbe8;border:1px solid #e8d99a;border-radius:8px;padding:10px 14px;
+font-size:13px;margin-top:10px}
+"""
+
+
+def card(title, badges, desc_html, img_b64=None, table_html=""):
+ img = f'
' if img_b64 else ""
+ return (f'{title}
{badges}
'
+ f'{desc_html}{table_html}{img}
')
+
+
+def main():
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+ ver = (PROJECT_ROOT / "VERSION").read_text().strip()
+ print("genero grafici (episodi reali)...")
+ panel = _daily_panel()
+
+ g_w = chart_weights()
+ g_mr01 = chart_fade("scripts.strategies.MR01_bollinger_fade", "BTC",
+ dict(trend_max=3.0, ema_long=200), mr01_bands,
+ "MR01 — fade della banda di Bollinger (episodio reale BTC)")
+ g_mr02 = chart_fade("scripts.strategies.MR02_donchian_fade", "ETH",
+ dict(trend_max=3.0, ema_long=200), mr02_bands,
+ "MR02 — fade della rottura del canale Donchian (episodio reale ETH)")
+ g_mr07 = chart_fade("scripts.strategies.MR07_return_reversal", "BTC",
+ dict(trend_max=3.0, ema_long=200),
+ lambda ax, df, lo, hi, t: None,
+ "MR07 — fade del rendimento estremo (episodio reale BTC)",
+ panel_fn=mr07_panel)
+ g_e16 = chart_exit16()
+ g_dip = chart_dip01()
+ g_tr = chart_tr01()
+ g_rot = chart_rot02(panel)
+ g_pr = chart_pr01()
+ g_tsm = chart_tsm01(panel)
+ g_sh = chart_sh01()
+
+ B = lambda f, t: f'{t}'
+ real = B("real", "ESECUZIONE REALE (testnet)")
+ sim = B("sim", "SIMULATO")
+
+ c_mr01 = card("MR01 — Bollinger Fade (BTC, ETH)", B("fade", "FADE") + real, """
+Quando il close chiude fuori dalla banda ±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).
""", g_mr01)
+
+ c_mr02 = card("MR02 — Donchian Fade (BTC, ETH)", B("fade", "FADE") + real, """
+Fada la rottura degli estremi del canale 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.
""", g_mr02)
+
+ c_mr07 = card("MR07 — Return Reversal (BTC, ETH)", B("fade", "FADE") + real, """
+Guarda il rendimento della singola barra: 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).
""", g_mr07)
+
+ c_e16 = card("EXIT-16 — perché lo stop scatta solo sul CLOSE", B("fade", "MECCANISMO COMUNE"), """
+Scoperta chiave della ricerca exit (34 agenti, 23 famiglie): gli stop intrabar da wick
+sono falsi negativi — l'overshoot che buca lo stop e rientra è esattamente il movimento che
+la fade sta comprando. Con EXIT-16 lo SL intrabar è disattivato: si esce solo se il close
+della barra completata sfonda il livello di 0.5·ATR. Il TP intrabar resta. Impatto sul
+portafoglio: OOS Sharpe 8.82→10.06. Esteso oggi anche a DIP01 (grid 36/36).
""", g_e16)
+
+ c_dip = card("DIP01 — Dip Buy (BTC)", B("honest", "HONEST") + real, """
+Compra il dip: 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).
""", g_dip)
+
+ c_tr = card("TR01 — Basket Trend (4h)", B("honest", "HONEST") + sim, """
+Trend-following difensivo: 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.
""", g_tr)
+
+ c_rot = card("ROT02 — Dual Momentum (1d)", B("honest", "HONEST") + sim, """
+Rotazione: 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<SMA100. Diversificare su 3
+asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.
""", g_rot)
+
+ c_pr = card("PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL)",
+ B("pairs", "PAIRS") + sim, """
+Market-neutral: quando il rapporto fra due asset si allontana troppo dalla sua media
+(|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 universale 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).
""", g_pr)
+
+ c_tsm = card("TSM01 — TSMOM (1d)", B("tsm", "TSM") + sim, """
+Long sugli asset con consenso pieno di momentum su 3 orizzonti (3/6/12 mesi),
+gross 0.30, cash totale se BTC<SMA100. Mai un anno negativo nel backtest. Non è un motore di
+ritorno: è il diversificatore che lavora nei regimi in cui le fade soffrono.
+Attualmente flat by-design (risk-off).
""", g_tsm)
+
+ c_sh = card("SH01 — Shape-ML (BTC, ETH)", B("shape", "SHAPE") + sim, """
+Una LogisticRegression legge 17 feature della forma delle ultime 24 barre e
+predice il segno del rendimento a 12 barre; entra solo se la probabilità supera 0.58, esce a
+orizzonte. Training walk-forward causale (mai dati futuri). Win-rate ~50%: l'edge è
+nell'asimmetria, non nella frequenza. Senza stop-loss by design (ogni stop testato rompe
+l'edge): la coda si gestisce dimezzando il peso della famiglia (cap 5.88%).
+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).
""", g_sh)
+
+ html = f"""
+PythagorasGoal — Strategie attive PORT06
+
+
PythagorasGoal — Strategie attive
+
Portafoglio live PORT06 (17 sleeve, capitale pool €2.000, leva 2x) ·
+v{ver} · generato {now} · backtest canonico: FULL Sharpe 6.61 / DD 3.58% — OOS Sharpe 8.77 / DD 1.34%
+
+
Tre famiglie principali quasi scorrelate fra loro (fade↔honest ~0.05, pairs ~0.02-0.09,
+shape ~0.08): la diversificazione è la leva anti-drawdown. Pesi equal con tetti per famiglia,
+ribilancio giornaliero su capitale pool condiviso. Fee Deribit 0.10% round-trip incluse ovunque;
+ogni meccanismo live è passato da un gate out-of-sample a livello di portafoglio.
+

+
+
FADE — mean-reversion intraday 1h (6 sleeve × 6.47%)
+
Tesi della famiglia: sui perpetui crypto l'edge è la reversione:
+i movimenti estremi rientrano (i breakout falliscono — l'intera famiglia squeeze-breakout è stata
+scartata come artefatto di look-ahead). Le fade vendono l'eccesso e comprano il panico, con tre
+protezioni comuni: filtro trend (salta il segnale se il prezzo è oltre 3·ATR dalla EMA200 —
+non si fada un crollo/parabolica), EXIT-16 (stop solo sul close confermato, vedi sotto) e
+min_tp_frac (salta i micro-trade col TP entro le fee).
+{c_mr01}
+{c_mr02}
+{c_mr07}
+{c_e16}
+
HONEST — long-only multi-regime (3 sleeve × 6.47%)
+{c_dip}
+{c_tr}
+{c_rot}
+
PAIRS — spread reversion market-neutral (5 coppie × 5.88%, famiglia ≤33%)
+{c_pr}
+
TSM — trend-following multi-orizzonte (1 sleeve × 6.47%)
+{c_tsm}
+
SHAPE — ML morfologico (2 sleeve × 2.94%, famiglia ≤5.88%)
+{c_sh}
+
+
Metodologia
+
+- Fee sempre incluse: 0.10% round-trip taker Deribit (misurate reali = assunte). Molte operazioni = morte per fee: ogni strategia regge lo stress a fee doppie.
+- Niente look-ahead: direzione e prezzo decisi solo con dati fino al close corrente; barre in formazione escluse (lezione EXIT-16). La famiglia squeeze (accuratezze 76-82%) è stata scartata proprio per questo artefatto.
+- Gate out-of-sample: nessun meccanismo va in produzione senza migliorare (o non degradare) il portafoglio FULL e OOS — robusto individualmente ≠ migliora PORT06.
+- Esecuzione reale shadow: i 7 sleeve single-leg eseguono ordini reali su Deribit testnet accanto al sim (TP limit al livello, disaster-stop −30% on-book, fee reali verificate dai trade); divergenze sim/reale ≥100bps alertate su Telegram.
+
+
Generato da scripts/analysis/make_strategy_doc.py — grafici da episodi reali sui dati parquet locali.
+
"""
+
+ OUT.write_text(html)
+ print(f"OK -> {OUT} ({OUT.stat().st_size//1024} KB)")
+
+
+if __name__ == "__main__":
+ main()