"""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_xs01(): """Book corrente (long perdenti/short vincenti) + dispersione cross-section col gate.""" from scripts.strategies.XS01_cross_sectional import aligned_panel, UNIVERSE, LB M = aligned_panel() logC = np.log(M.values) ts = pd.to_datetime(M.index, unit="ms", utc=True) dm = logC[-1] - logC[-1 - LB] dm = dm - dm.mean() w = -dm / np.sum(np.abs(dm)) fig, (ax, ax2) = plt.subplots(1, 2, figsize=(8.6, 3.4), width_ratios=[1, 1.5]) ax.bar(UNIVERSE, w * 100, color=[C_TP if x > 0 else C_SL for x in w]) ax.set_ylabel("peso book %") ax.tick_params(axis="x", rotation=60) ax.set_title("book: long i perdenti relativi,\nshort i vincenti (mom 48h demeaned)", loc="left", fontweight="bold", fontsize=9) D = logC[LB:] - logC[:-LB] disp = pd.Series(D.std(axis=1), index=ts[LB:]).iloc[-24 * 180:] ax2.plot(disp.index, disp.values, color="#333", lw=0.7) ax2.axhline(0.0313, color=C_SL, ls="--", lw=1.2) on = disp.values >= 0.0313 ax2.fill_between(disp.index, 0, disp.values.max(), where=on, alpha=0.10, color=C_TP) ax2.annotate("disp_min 0.0313 (p50 TRAIN)\nentry solo sopra soglia", (disp.index[-1], 0.0313), xytext=(-150, 10), textcoords="offset points", color=C_SL, fontsize=8) ax2.set_title("dispersion-gate: std cross-section del momentum", loc="left", fontweight="bold", fontsize=9) 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", "XSEC": "#8c564b"} 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) # ------------------------------------------------------- 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'{m}' for m in per_market) sub = "".join("trdPnL%DD%" 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"{n}{d['pnl']:+.0f}{d['dd']:.0f}" else: cells += "———" rows.append(f"{y}{cells}") 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"{n}{pnl:+.0f}{tot_dd:.0f}" rows.append(f'TOT{cells}') 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 2021 — gli anni 2018-2020 (regime microstrutturale " "diverso, spesso negativi) sono mostrati per onestà storica.") return (f'{head}{sub}' + "".join(rows) + f'
anno

{note or base}

') 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 = [], {} from scripts.analysis.pairs_research import pairs_sim_flat # le 5 coppie 1h universali + il BLEND ETH/BTC 15m flat-skip (mezza size = sleeve live) runs = [(f"{a}/{b}", lambda a=a, b=b, p=p: pairs_sim(a, b, **p)) for a, b, p in PAIRS_CFG] runs.append(("ETH/BTC·15m", lambda: 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))) for tag, fn in runs: r = fn() 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"{data[c]['yearly'][y]:+.0f} ({data[c]['n'].get(y,0)}" f", dd {data[c]['ydd'].get(y,0):.0f})") if y in data[c]["yearly"] else "—" for c in cols) rows.append(f"{y}{cells}") tot = "".join(f"{sum(data[c]['yearly'].values()):+.0f} " f"({data[c]['trades']}, dd {data[c]['dd']:.0f})" for c in cols) rows.append(f'TOT{tot}') head = "".join(f"{c}" for c in cols) return (f'{head}' + "".join(rows) + "
anno
" '

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 2021; gli anni precedenti " "(spesso negativi) sono mostrati per onestà storica.

") 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_xs01(): """XS01 per-anno dall'engine canonico (UNGATED, come il backtest di portafoglio).""" from scripts.strategies.XS01_cross_sectional import xsec_sim r = xsec_sim() s = pd.Series(r["eq_v"], index=pd.to_datetime(r["eq_ts"], utc=True)) years = {} for y in sorted(r["yearly"]): g = s[s.index.year == y] dd = float(((g.cummax() - g) / g.cummax()).max() * 100) if len(g) else 0.0 years[int(y)] = {"n": r["yearly_n"].get(y, 0), "pnl": r["yearly"][y], "dd": dd} pk = s.cummax() note = ("PnL = Σ rendimenti netti per trade del book (%, gross 1, fee 0.20% RT/book = " "turnover 2×0.10%); DD dall'equity compounding (pos 0.15, leva 3x convenzione " "test). Engine canonico SENZA dispersion-gate (il gate agisce solo sul path " "live, come trend/hurst sulle fade → il live farà meglio del backtest).") return yearly_table({"XS01 (universo 8)": (years, float(((pk - s) / pk).max() * 100))}, 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} .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-xsec{background:#8c564b} .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() 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() t_xs = stats_xs01() 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, "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() g_xs = chart_xs01() # metriche canoniche del default per l'intestazione (sempre aggiornate) pr = PORTFOLIOS["PORT06"].backtest() n_def = len(PORTFOLIOS["PORT06"].sleeve_ids) hdr = (f"FULL Sharpe {pr.full['sharpe']:.2f} / DD {pr.full['dd']:.2f}% — " f"OOS Sharpe {pr.oos['sharpe']:.2f} / DD {pr.oos['dd']:.2f}%") 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, yearly_table({"BTC": st_fade["MR01"]["BTC"], "ETH": st_fade["MR01"]["ETH"]})) 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, yearly_table({"BTC": st_fade["MR02"]["BTC"], "ETH": st_fade["MR02"]["ETH"]})) 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, 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"), """

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 anche a DIP01 (2026-06-07, 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, yearly_table(st_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, t_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, t_rot) c_pr = card("PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL + ETH/BTC 15m)", B("pairs", "PAIRS") + real, """

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).

BLEND timeframe (nuovo, 2026-06-09): ETH/BTC gira anche a 15m accanto al 1h (config n=66, |z|≥1.67, exit |z|≤1.0 o 35 barre). Origine: gioco "Blind Traders" (100 agenti ciechi su dati anonimi); testato col gate PORT06 — decorrelato dal 1h (corr 0.37), robusto (16/16), e l'edge regge anche filtrando le candele flat ETH 15m (flat-skip: niente ingresso/uscita su barre stale O=H=L=C). Worker validato (replay == backtest). A mezza size (blend-tilt prudente sul caveat slippage): porta il PORT06 a FULL Sharpe ~7.2 / OOS ~9.7.

Esecuzione reale a 2 gambe su Deribit testnet (PairsExecutionClient): open/close long A / short B, leg-risk unwind, mai close_position.

""", g_pr, t_pairs) 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, t_tsm) c_sh = card("SH01 — Shape-ML (BTC, ETH)", B("shape", "SHAPE") + real, """

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%).

Esecuzione reale single-leg su Deribit testnet: niente TP/SL, chiusura a orizzonte H=12 (market reduce-only), disaster-bracket on-book come unica protezione di coda — è il diversificatore più decorrelato del portafoglio.

Fix punto-10 (2026-06-07): 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, yearly_table(st_sh)) c_xs = card("XS01 — Cross-Sectional Reversion (8 asset)", B("xsec", "XSEC") + sim, """

Famiglia nuova (2026-06-09): ogni 12 ore classifica 8 crypto (BTC, ETH, LTC, ADA, SOL, BNB, XRP, DOGE) per rendimento a 48 ore e va long i perdenti relativi / short i vincenti (peso ∝ −(ret − media cross-section)), market-neutral gross 1. Cattura il fattore reversione cross-sezionale — distinto dai pairs (pairwise) e dai fade (single-asset): correlazione ~0 con entrambi. Gate PORT06: OOS Sharpe 9.66→10.07, FULL DD 3.68→3.46. Plateau robusto (lb 12-72 × hold 6-24 tutte OOS+).

Dispersion-gate (2026-06-10, v1.1.20): entra solo se la dispersione cross-section del momentum ≥ 0.0313 (mediana TRAIN) — la reversione paga quando c'è dispersione da far rientrare. Diagnostica monotona TRAIN e OOS, plateau p30-p70, standalone Sharpe 2.50→3.46 (regge fee 2x), PORT06 OOS 10.07→10.37 a DD pari. Solo path live (backtest canonico non filtrato → il live farà meglio del backtest).

Phase-tranching (2026-06-11, v1.1.21): la fase del roll non-sovrapposto è arbitraria e da sola muove lo Sharpe daily 1.52-2.33 e il DD 14-33% (timing-luck) → live gira con 3 sub-book sfasati di 4 barre su capitale comune (PnL/3 ciascuno): ensemble di fase SENZA parametri fittati. Gate con plateau (K=2 e K=3 entrambi promossi): PORT06 OOS Sharpe 10.07→10.15, DD 1.48→1.38 a FULL pari. Worker validato esatto (K=1 == backtest; K=3 == unione fasi).

8 gambe → niente esecuzione reale: gira PAPER come i book multi-asset (il rumore di arrotondamento a €2k dominerebbe). Worker validato (replay == backtest esatto).

""", g_xs, t_xs) html = f""" PythagorasGoal — Strategie attive PORT06

PythagorasGoal — Strategie attive

Portafoglio live PORT06 — definizione {n_def} sleeve; pool live real-only 15 sleeve (i 4 book multi-asset TR01/ROT02/TSM01/XS01 girano in statistica, fuori dal capitale-pool). Capitale pool €2.000, leva 2x, real-truth ledger · v{ver} · generato {now} · backtest canonico: {hdr}

Famiglie quasi scorrelate fra loro (fade↔honest ~0.05, pairs ~0.02-0.09, shape ~0.08, xsec ~0): 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 × 5.69%)

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 × 5.69%)

{c_dip} {c_tr} {c_rot}

PAIRS — spread reversion market-neutral (6 sleeve × 5.26%: 5 coppie 1h + ETH/BTC 15m a mezza size, famiglia ≤33%)

{c_pr}

TSM — trend-following multi-orizzonte (1 sleeve × 5.69%)

{c_tsm}

XSEC — reversione cross-sectional (1 sleeve × 5.69%)

{c_xs}

SHAPE — ML morfologico (2 sleeve × 2.94%, famiglia ≤5.88%)

{c_sh}

Metodologia

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()