37ffe92498
Era fermo al 2026-06-12 06:17 (pre-swap): diceva fade 'intraday 1h' e 'leva 2x'. Rigenerato -> numeri post-swap (FULL Sh 8.13/DD 2.47%, OOS 10.86/2.09%) + fix etichette hardcoded nel generatore (header FADE 1h->15m, leva 2x->3x, nota tabella). E' la scheda linkata dal modal della dashboard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
852 lines
44 KiB
Python
852 lines
44 KiB
Python
"""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]):
|
||
# clamp: l'engine live-path lascia j >= n per il trade aperto al bordo
|
||
# della serie (con dati freschi a fine-serie c'e' quasi sempre)
|
||
ey = ts.iloc[i].year
|
||
xy = ts.iloc[min(j, len(ts) - 1)].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'<th colspan="3">{m}</th>' for m in per_market)
|
||
sub = "".join("<th>trd</th><th>PnL%</th><th>DD%</th>" 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"<td>{n}</td><td>{d['pnl']:+.0f}</td><td>{d['dd']:.0f}</td>"
|
||
else:
|
||
cells += "<td>—</td><td>—</td><td>—</td>"
|
||
rows.append(f"<tr><td><b>{y}</b></td>{cells}</tr>")
|
||
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"<td><b>{n}</b></td><td><b>{pnl:+.0f}</b></td><td><b>{tot_dd:.0f}</b></td>"
|
||
rows.append(f'<tr style="background:#f0f2f5"><td><b>TOT</b></td>{cells}</tr>')
|
||
base = ("PnL = Σ rendimenti netti per trade (%, leva 3x = config live dal 2026-06-12, "
|
||
"fee 0.10% RT); 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 <b>2021</b> — gli anni 2018-2020 (regime microstrutturale "
|
||
"diverso, spesso negativi) sono mostrati per onestà storica.")
|
||
return (f'<table><tr><th rowspan="2">anno</th>{head}</tr><tr>{sub}</tr>'
|
||
+ "".join(rows) + f'</table><p class="sub">{note or base}</p>')
|
||
|
||
|
||
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"<td>{data[c]['yearly'][y]:+.0f} <span class='sub'>({data[c]['n'].get(y,0)}"
|
||
f", dd {data[c]['ydd'].get(y,0):.0f})</span></td>")
|
||
if y in data[c]["yearly"] else "<td>—</td>" for c in cols)
|
||
rows.append(f"<tr><td><b>{y}</b></td>{cells}</tr>")
|
||
tot = "".join(f"<td><b>{sum(data[c]['yearly'].values()):+.0f}</b> "
|
||
f"<span class='sub'>({data[c]['trades']}, dd {data[c]['dd']:.0f})</span></td>"
|
||
for c in cols)
|
||
rows.append(f'<tr style="background:#f0f2f5"><td><b>TOT</b></td>{tot}</tr>')
|
||
head = "".join(f"<th>{c}</th>" for c in cols)
|
||
return (f'<table><tr><th>anno</th>{head}</tr>' + "".join(rows) + "</table>"
|
||
'<p class="sub">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 <b>2021</b>; gli anni precedenti "
|
||
"(spesso negativi) sono mostrati per onestà storica.</p>")
|
||
|
||
|
||
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'<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()
|
||
|
||
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'<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,
|
||
yearly_table({"BTC": st_fade["MR01"]["BTC"], "ETH": st_fade["MR01"]["ETH"]}))
|
||
|
||
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,
|
||
yearly_table({"BTC": st_fade["MR02"]["BTC"], "ETH": st_fade["MR02"]["ETH"]}))
|
||
|
||
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,
|
||
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"), """
|
||
<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 anche a DIP01 (2026-06-07, 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, yearly_table(st_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, t_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<SMA100. Diversificare su 3
|
||
asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""", 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, """
|
||
<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>
|
||
<p class='sub'><b>BLEND timeframe (nuovo, 2026-06-09)</b>: ETH/BTC gira anche a <b>15m</b> 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 — <b>decorrelato dal 1h (corr 0.37)</b>, robusto (16/16),
|
||
e l'edge regge anche filtrando le candele flat ETH 15m (<b>flat-skip</b>: niente ingresso/uscita su
|
||
barre stale O=H=L=C). Worker validato (replay == backtest). A <b>mezza size</b> (blend-tilt prudente
|
||
sul caveat slippage): porta il PORT06 a FULL Sharpe ~7.2 / OOS ~9.7.</p>
|
||
<p class='sub'>Esecuzione reale a 2 gambe su Deribit testnet (<code>PairsExecutionClient</code>):
|
||
open/close long A / short B, leg-risk unwind, mai <code>close_position</code>.</p>""", g_pr, t_pairs)
|
||
|
||
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<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, t_tsm)
|
||
|
||
c_sh = card("SH01 — Shape-ML (BTC, ETH)", B("shape", "SHAPE") + real, """
|
||
<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'>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.</p>
|
||
<p class='sub'>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).</p>""", g_sh, yearly_table(st_sh))
|
||
|
||
c_xs = card("XS01 — Cross-Sectional Reversion (8 asset)", B("xsec", "XSEC") + sim, """
|
||
<p><b>Famiglia nuova (2026-06-09)</b>: ogni 12 ore classifica 8 crypto (BTC, ETH, LTC, ADA,
|
||
SOL, BNB, XRP, DOGE) per rendimento a 48 ore e va <b>long i perdenti relativi / short i
|
||
vincenti</b> (peso ∝ −(ret − media cross-section)), market-neutral gross 1. Cattura il
|
||
<i>fattore</i> 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+).</p>
|
||
<p class='sub'><b>Dispersion-gate (2026-06-10, v1.1.20)</b>: 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→<b>10.37</b> a DD pari.
|
||
Solo path live (backtest canonico non filtrato → il live farà meglio del backtest).</p>
|
||
<p class='sub'><b>Phase-tranching (2026-06-11, v1.1.21)</b>: 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 <b>3 sub-book sfasati</b> 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).</p>
|
||
<p class='sub'>8 gambe → niente esecuzione reale: gira PAPER come i book multi-asset
|
||
(il rumore di arrotondamento a €2k dominerebbe). Worker validato (replay == backtest esatto).</p>""",
|
||
g_xs, t_xs)
|
||
|
||
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> — definizione {n_def} sleeve; <b>pool live real-only 15 sleeve</b>
|
||
(i 4 book multi-asset TR01/ROT02/TSM01/XS01 girano in statistica, fuori dal capitale-pool).
|
||
Capitale pool €2.000, <b>leva 3x</b> (dal 2026-06-12), <b>real-truth ledger</b> ·
|
||
v{ver} · generato {now} · backtest canonico: {hdr}</p>
|
||
<div class="card">
|
||
<p>Famiglie quasi <b>scorrelate</b> 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.</p>
|
||
<img src="data:image/png;base64,{g_w}"></div>
|
||
|
||
<h2>FADE — mean-reversion intraday 15m <span class="sub">(6 sleeve × 5.69%, swap 1h→15m dal 2026-06-12)</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 × 5.69%)</span></h2>
|
||
{c_dip}
|
||
{c_tr}
|
||
{c_rot}
|
||
<h2>PAIRS — spread reversion market-neutral <span class="sub">(6 sleeve × 5.26%: 5 coppie 1h + ETH/BTC 15m a mezza size, famiglia ≤33%)</span></h2>
|
||
{c_pr}
|
||
<h2>TSM — trend-following multi-orizzonte <span class="sub">(1 sleeve × 5.69%)</span></h2>
|
||
{c_tsm}
|
||
<h2>XSEC — reversione cross-sectional <span class="sub">(1 sleeve × 5.69%)</span></h2>
|
||
{c_xs}
|
||
<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 (15 sleeve su 19)</b>: i 6 fade + DIP01 (single-leg, TP limit al livello, disaster-stop −30% on-book), i 6 pairs incl. <b>ETH/BTC 15m flat-skip</b> (2 gambe, leg-risk unwind) e i 2 SH01 (single-leg, exit a orizzonte, niente TP/SL) eseguono ordini reali su Deribit testnet. Restano simulati solo i book multi-asset TR01/ROT02/TSM01/XS01 (bloccati dal capitale: serve ~€20k per il rumore di arrotondamento). Fee reali verificate dai trade; divergenze sim/reale ≥100bps alertate su Telegram.</li>
|
||
<li><b>Real-truth ledger (2026-06-10)</b>: il capitale dei worker eseguiti si aggiorna col PnL dei <b>fill reali</b> (fee reali incluse), non col sim — equity, pesi e notional derivano dai soldi veri sul conto; il sim resta diagnostica nel log (fallback dichiarato solo se il trade reale non è mai esistito). Le decisioni di entry/exit restano guidate dal feed.</li>
|
||
<li><b>Verità d'esecuzione e netting (2026-06-11, v1.1.23-25)</b>: il tocco TP va confermato dal <b>book reale</b> (gate TP_PHANTOM: resting a zero-fill + prezzo lontano dal livello = wick fantasma del feed → exit soppressa); i ledger usano l'amount <b>fillato</b>, mai il richiesto; le chiusure sono <b>netting-aware</b> (residuo reduce-only cappato/respinto dal netting di conto → rieseguito in market puro: niente gambe orfane quando worker opposti condividono lo strumento). Reti di sicurezza: reconciler orario conto-vs-libri (alert <code>ACCOUNT_DRIFT</code>), eventi <code>NET_CLOSE</code>/<code>PAIR_LEG_ORPHAN</code>/<code>REAL_CLOSE_PARTIAL</code>, drift-monitor giornaliero per famiglia (warn sotto il p5 storico).</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()
|