"""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'
{badges}
' f'{desc_html}{table_html}{img}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"""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.
Generato da scripts/analysis/make_strategy_doc.py — grafici da episodi reali sui dati parquet locali.