Files
PythagorasGoal/scripts/analysis/make_strategy_doc.py
T
Adriano Dal Pastro bc0ad3e86f docs: documento HTML strategie attive PORT06 (descrizioni + grafici da episodi reali)
scripts/analysis/make_strategy_doc.py genera docs/report/strategie_attive.html
(autocontenuto, PNG base64): le 6 strategie attive per famiglia con tesi,
config live, badge sim/reale e 11 grafici costruiti su EPISODI REALI dai
parquet locali — fade con bande/canale/z e trade annotato (entry/TP/SL),
explainer EXIT-16 (wick che buca lo SL senza conferma sul close), DIP01,
TR01 EMA 4h, ROT02 (gate regime + ranking momentum), PR01 (gambe normalizzate
+ z-score del ratio con soglie), TSM01 (risk-off + heatmap consenso),
SH01 (finestra forma + schema walk-forward), pesi cap del portafoglio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:55:45 +00:00

571 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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'<img src="data:image/png;base64,{img_b64}">' if img_b64 else ""
return (f'<div class="card"><h3>{title}</h3><p>{badges}</p>'
f'{desc_html}{table_html}{img}</div>')
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'<span class="badge b-{f}">{t}</span>'
real = B("real", "ESECUZIONE REALE (testnet)")
sim = B("sim", "SIMULATO")
c_mr01 = card("MR01 — Bollinger Fade (BTC, ETH)", B("fade", "FADE") + real, """
<p>Quando il <b>close chiude fuori dalla banda</b> ±2.5σ attorno alla SMA50, entra CONTRO il
movimento (short sopra, long sotto). TP alla media (il prezzo "torna a casa"), SL a 2·ATR,
time-limit 24 barre. Edge OOS validato: BTC +201% / ETH +1238% (fee incluse).</p>""", g_mr01)
c_mr02 = card("MR02 — Donchian Fade (BTC, ETH)", B("fade", "FADE") + real, """
<p>Fada la <b>rottura degli estremi del canale</b> Donchian a 20 barre (max/min recenti):
short sulla rottura del massimo, long sulla rottura del minimo. TP al centro del canale.
Stessa tesi di MR01 con trigger diverso → si combinano bene.</p>""", g_mr02)
c_mr07 = card("MR07 — Return Reversal (BTC, ETH)", B("fade", "FADE") + real, """
<p>Guarda il <b>rendimento della singola barra</b>: se lo z-score supera ±3.5 (movimento
estremo in un'ora), fada il movimento. Exit in multipli di ATR. È la fade più selettiva
(esposizione ~8% del tempo).</p>""", g_mr07)
c_e16 = card("EXIT-16 — perché lo stop scatta solo sul CLOSE", B("fade", "MECCANISMO COMUNE"), """
<p>Scoperta chiave della ricerca exit (34 agenti, 23 famiglie): <b>gli stop intrabar da wick
sono falsi negativi</b> — 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 <b>close</b>
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).</p>""", g_e16)
c_dip = card("DIP01 — Dip Buy (BTC)", B("honest", "HONEST") + real, """
<p>Compra il <b>dip</b>: quando lo z-score del prezzo incrocia sotto 2.5 (sell-off rapido),
entra long. TP alla SMA50, EXIT-16 sul SL, max 24 barre. È l'unico sleeve BTC con round-trip
reali su Deribit testnet (TP limit resting + disaster-stop a 30% sul book).</p>""", g_dip)
c_tr = card("TR01 — Basket Trend (4h)", B("honest", "HONEST") + sim, """
<p><b>Trend-following difensivo</b>: long quando EMA20>EMA100 sulle 4 ore, flat altrimenti,
su un paniere equal-weight di 5 asset (BNB, BTC, DOGE, SOL, XRP). Cattura i trend lunghi che
le fade per costruzione non prendono. Valuta solo barre 4h COMPLETE.</p>""", g_tr)
c_rot = card("ROT02 — Dual Momentum (1d)", B("honest", "HONEST") + sim, """
<p><b>Rotazione</b>: ogni giorno ordina 8 crypto per momentum a 60 giorni e tiene le top-3
(solo se positive), gross 0.45. Gate di regime: tutto cash se BTC&lt;SMA100. Diversificare su 3
asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""", g_rot)
c_pr = card("PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL)",
B("pairs", "PAIRS") + sim, """
<p><b>Market-neutral</b>: 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 <b>universale</b> per tutte le coppie (niente tuning
per-coppia = anti-overfit). Correlazione col mercato ~0.05: rende anche quando il mercato è fermo.
Fee su 2 gambe. Senza stop per design → position size ridotto a 0.20 (esposizione ≈ validato).</p>""", g_pr)
c_tsm = card("TSM01 — TSMOM (1d)", B("tsm", "TSM") + sim, """
<p>Long sugli asset con <b>consenso pieno</b> di momentum su 3 orizzonti (3/6/12 mesi),
gross 0.30, cash totale se BTC&lt;SMA100. Mai un anno negativo nel backtest. Non è un motore di
ritorno: è il <b>diversificatore</b> che lavora nei regimi in cui le fade soffrono.
Attualmente flat by-design (risk-off).</p>""", g_tsm)
c_sh = card("SH01 — Shape-ML (BTC, ETH)", B("shape", "SHAPE") + sim, """
<p>Una <b>LogisticRegression</b> legge 17 feature della <i>forma</i> delle ultime 24 barre e
predice il segno del rendimento a 12 barre; entra solo se la probabilità supera 0.58, esce a
orizzonte. Training <b>walk-forward causale</b> (mai dati futuri). Win-rate ~50%: l'edge è
nell'asimmetria, non nella frequenza. <b>Senza stop-loss by design</b> (ogni stop testato rompe
l'edge): la coda si gestisce dimezzando il peso della famiglia (cap 5.88%).</p>
<p class='sub'>Fix di oggi (punto-10): il training live usa la storia COMPLETA dal parquet
locale (il regime corto a 365g non era robusto: trade-rate 22% vs 10% validato).</p>""", g_sh)
html = f"""<!doctype html><html lang="it"><head><meta charset="utf-8">
<title>PythagorasGoal — Strategie attive PORT06</title><style>{CSS}</style></head>
<body><div class="wrap">
<h1>PythagorasGoal — Strategie attive</h1>
<p class="sub">Portafoglio live <b>PORT06</b> (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%</p>
<div class="card">
<p>Tre famiglie principali quasi <b>scorrelate</b> 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.</p>
<img src="data:image/png;base64,{g_w}"></div>
<h2>FADE — mean-reversion intraday 1h <span class="sub">(6 sleeve × 6.47%)</span></h2>
<div class="note"><b>Tesi della famiglia:</b> sui perpetui crypto l'edge è la <i>reversione</i>:
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: <b>filtro trend</b> (salta il segnale se il prezzo è oltre 3·ATR dalla EMA200 —
non si fada un crollo/parabolica), <b>EXIT-16</b> (stop solo sul close confermato, vedi sotto) e
<b>min_tp_frac</b> (salta i micro-trade col TP entro le fee).</div>
{c_mr01}
{c_mr02}
{c_mr07}
{c_e16}
<h2>HONEST — long-only multi-regime <span class="sub">(3 sleeve × 6.47%)</span></h2>
{c_dip}
{c_tr}
{c_rot}
<h2>PAIRS — spread reversion market-neutral <span class="sub">(5 coppie × 5.88%, famiglia ≤33%)</span></h2>
{c_pr}
<h2>TSM — trend-following multi-orizzonte <span class="sub">(1 sleeve × 6.47%)</span></h2>
{c_tsm}
<h2>SHAPE — ML morfologico <span class="sub">(2 sleeve × 2.94%, famiglia ≤5.88%)</span></h2>
{c_sh}
<h2>Metodologia</h2>
<div class="card"><ul style="font-size:13.5px;line-height:1.7">
<li><b>Fee sempre incluse</b>: 0.10% round-trip taker Deribit (misurate reali = assunte). Molte operazioni = morte per fee: ogni strategia regge lo stress a fee doppie.</li>
<li><b>Niente look-ahead</b>: 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.</li>
<li><b>Gate out-of-sample</b>: nessun meccanismo va in produzione senza migliorare (o non degradare) il portafoglio FULL e OOS — robusto individualmente ≠ migliora PORT06.</li>
<li><b>Esecuzione reale shadow</b>: 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.</li>
</ul></div>
<p class="sub">Generato da <code>scripts/analysis/make_strategy_doc.py</code> — grafici da episodi reali sui dati parquet locali.</p>
</div></body></html>"""
OUT.write_text(html)
print(f"OK -> {OUT} ({OUT.stat().st_size//1024} KB)")
if __name__ == "__main__":
main()