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

+
+

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

+
+

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