chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita

Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 15:16:03 +00:00
parent 8401a280b9
commit 14522262e6
383 changed files with 1971 additions and 779 deletions
View File
+142
View File
@@ -0,0 +1,142 @@
"""REGRESSION-LOCK COMUNE dei gate PORT06 live (exit16 / trendmax / dip01).
Queste funzioni erano copiate quasi-verbatim in exit16_port06_impact.py,
trendmax_port06_impact.py e dip01_exit16_impact.py. Sono il regression-lock
delle DECISIONI LIVE (EXIT-16, swap hurst->trend, DIP01 EXIT-16): la copy-drift
fra le copie avrebbe corrotto i verdetti, quindi vivono qui in un'unica copia.
NON cambiare la matematica: i gate devono restare riproducibili byte-a-byte.
Se un nuovo gate richiede un comportamento diverso, PARAMETRIZZARE (come fu
fatto per hurst_mask/trend_max), mai biforcare una copia.
Contenuto:
build_trades_variant : replay ESATTO di risk_management.build_trades sulle
fade (mode="orig" == canonico), con i rami varianti
EXIT-16 (mode="exit16"), filtro trend (trend_max) e
loss-guard Hurst (hurst_mask) parametrici.
equity_from_trades : trade -> equity giornaliera normalizzata su IDX
(stesso flusso di combine_portfolio.fade_daily_equity).
port_metrics : metriche FULL/OOS del portafoglio con la STESSA
matematica pesi di Portfolio.backtest (weight_vector
su tutti gli sleeve, ribilancio come port_returns).
dd : max drawdown % di una equity.
NB: l'engine DIP01 (dip_trades in dip01_exit16_impact.py) NON e' una copia di
build_trades_variant ma un sibling deliberatamente diverso (long-only, mode
"orig_gap" gap-aware, j clampato a n-1 a fine serie, niente filtri trend/hurst)
-> resta nel suo script.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.strategy_research import atr
from scripts.analysis.risk_management import FEE_RT, LEV, POS, INIT
from scripts.analysis.combine_portfolio import (
_norm, IDX, port_returns, metrics, SPLIT,
)
from src.portfolio import weighting as W
BUFFER = 0.5 # EXIT-16 close-confirm (come in produzione)
EMA_LONG = 200
def build_trades_variant(ents, df, mode, trend_max, hurst_mask=None,
buffer=BUFFER, lev=LEV, fee_rt=FEE_RT, ema_long=EMA_LONG):
"""Replica ESATTA di risk_management.build_trades, con i rami varianti.
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
SL solo se il CLOSE sfonda sl0 -/+ buffer*ATR14[j], fill a close[j].
trend_max : None = filtro OFF; 3.0 = config live.
hurst_mask : bool[i]=True -> salta l'ingresso (loss-guard storico).
"""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
a = atr(df, 14)
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
fee = fee_rt * lev
out = []
last = -1
for e in ents:
i, d = e["i"], e["d"]
if i <= last or i + 1 >= n:
continue
if hurst_mask is not None and hurst_mask[i]:
continue
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
continue
entry = c[i]
tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]
break
if mode == "orig":
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hs:
exit_p = sl0
break
if ht:
exit_p = tp
break
if k == mb:
exit_p = c[j]
else: # exit16
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if ht:
exit_p = tp
break
aj = a[j] if np.isfinite(a[j]) else 0.0
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
(d == -1 and c[j] > sl0 + buffer * aj)
if confirm:
exit_p = c[j]
break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
out.append((i, j, ret))
last = j
return out
def equity_from_trades(df, trades):
"""Trade -> equity giornaliera su IDX (flusso di combine_portfolio.fade_daily_equity)."""
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
n = len(df)
eq = np.full(n, INIT, dtype=float)
cap = INIT
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * POS * ret, 10.0)
eq[j:] = cap
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
def port_metrics(members: dict[str, pd.Series], p):
"""Metriche (FULL, OOS) del portafoglio p con la STESSA matematica pesi cap
di Portfolio.backtest."""
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def dd(s):
"""Max drawdown % di una serie equity."""
pk = s.cummax()
return float(((pk - s) / pk).max() * 100)
+117
View File
@@ -0,0 +1,117 @@
"""ACCEL50 — Ricerca acceleratori verso l'obiettivo €50/giorno (2026-06-12).
Domanda: quali strategie/leve portano PIU' VELOCEMENTE a €50/g partendo da ~€2k?
Diario: docs/diary/2026-06-12-accel50.md. Esiti:
1. LEVA su PORT06 (acceleratore dominante, zero ricerca nuova).
La frontiera (scala lineare dei daily return canonici, fee pro-quota) mostra
che a Sharpe ~7-10 il vincolo non e' il rischio ma la taglia: lev 2->4 porta
gli anni-a-target da 3.3 a 1.2 con FULL DD 3.5->6.9%. Vedi lev_frontier().
2. FADE 15m (candidata NUOVA, validazione preliminare PASSATA).
MR01/MR02/MR07 a 15m con i parametri live 1h (trend_max=3, ema_long=200,
sl_confirm_atr=0.5, fee 0.10% RT): tutti e 6 gli sleeve positivi, OOS
2025-26 positivo ovunque (spesso > del 1h: 4x trade = compounding piu'
rapido), reggono fee 2x (Sh 1.6-2.9). BTC 15m MIGLIORA il 1h (MR01 Sh
3.37 vs 2.76 con meta' DD). Prossimo passo obbligato: gate PORT06
(correlazione col gemello 1h, parita' worker — infra 15m gia' esistente
dal BLEND pairs). Vedi fade15m_probe().
3. PAIRS NUOVE: BOCCIATE (stale-print illusion).
Lo sweep delle 19 coppie mai testate (config universale pre-registrata)
dava 8 candidate con Sharpe 1.5-4.3, MA le gambe alt hanno 88-98% di barre
flat (LTC 97%, ADA 98%, DOGE 91%, XRP 88%, BNB 88%) e con flat_skip=True
(fill solo su barre pulite) muoiono quasi tutte (BTC/ADA 4.33->0.17,
ETH/DOGE 3.79->0.46). Migliore superstite ETH/XRP a 1.34: inferiore alle
5 deployate -> niente. Stessa classe di illusione del XEX su DOGE/SOL
(vedi xex_divergence_research.py). PAXG idem: 92% flat su Deribit.
4. CAPITALE: a config attuale servono ~€24k per €50/g; ogni € aggiunto
accorcia linearmente (non e' una strategia ma domina ogni altra leva).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
def lev_frontier() -> None:
"""Frontiera di leva su PORT06: CAGR/DD/Sharpe e anni-a-€50/g per lev 1-6.
Modello: scala lineare dei daily return del backtest canonico (strumenti
lineari, fee proporzionali al notional). NON modella margine/code grasse."""
from scripts.portfolios._defs import PORTFOLIOS
from scripts.analysis.combine_portfolio import port_returns, SPLIT
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
p = PORTFOLIOS["PORT06"]
eq = all_sleeve_equities()
members = {sid: eq[sid] for sid in p.sleeve_ids}
w = p.weight_vector(sleeve_returns_df(p.sleeve_ids))
base = port_returns(members, w) # == live a lev 2 (parita' validata)
def dd(x):
c = (1 + x).cumprod()
return ((c - c.cummax()) / c.cummax()).min() * 100
def cagr(x):
c = (1 + x).cumprod()
return ((c.iloc[-1]) ** (365 / len(x)) - 1) * 100
print("lev CAGR_full% DD_full% CAGR_oos% DD_oos% K_per_50/g anni_da_2k")
for f, lev in [(0.5, 1), (1.0, 2), (1.5, 3), (2.0, 4), (2.5, 5), (3.0, 6)]:
r = base * f
roos = r.iloc[SPLIT:]
co = cagr(roos)
daily = (1 + co / 100) ** (1 / 365) - 1
k = 50 / daily if daily > 0 else float("inf")
anni = np.log(k / 2020) / np.log(1 + co / 100) if co > 0 else float("inf")
print(f"{lev:>3} {cagr(r):>11.0f} {dd(r):>9.2f} {co:>10.0f} {dd(roos):>8.2f} "
f"{k:>11,.0f} {max(anni, 0):>11.1f}")
def fade15m_probe() -> None:
"""MR01/02/07 a 15m vs 1h, parametri live, fee 0.10% e stress 2x."""
import importlib.util
import inspect
from src.strategies.base import Strategy
LIVEP = dict(trend_max=3.0, ema_long=200, sl_confirm_atr=0.5)
paths = {
"MR01": "scripts/strategies/MR01_bollinger_fade.py",
"MR02": "scripts/strategies/MR02_donchian_fade.py",
"MR07": "scripts/strategies/MR07_return_reversal.py",
}
for code, rel in paths.items():
spec = importlib.util.spec_from_file_location(code.lower(), PROJECT_ROOT / rel)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
cls = next(o for _, o in vars(m).items()
if inspect.isclass(o) and issubclass(o, Strategy) and o.__module__ == m.__name__)
s = cls()
for asset in ("BTC", "ETH"):
line = f"{code} {asset}: "
for tf in ("1h", "15m"):
r = s.backtest(asset, tf, **LIVEP)
if r is None:
line += f"{tf}: no-sig | "
continue
oos = sum(y.pnl for y in r.yearly if y.year >= 2025)
old = s.fee_rt
s.fee_rt = 0.002
r2 = s.backtest(asset, tf, **LIVEP)
s.fee_rt = old
line += (f"{tf}: Sh{r.sharpe:5.2f} DD{r.max_dd:5.1f}% n={r.trades:4d} "
f"oos25-26={oos:+8.0f} fee2x_Sh{r2.sharpe:5.2f} | ")
print(line)
if __name__ == "__main__":
print("=== 1. Frontiera di leva PORT06 ===")
lev_frontier()
print("\n=== 2. Fade 15m probe ===")
fade15m_probe()
+100
View File
@@ -0,0 +1,100 @@
"""CONFERMA su feed PURO Binance spot — la fade ha edge reale o era artefatto-print?
Il clean close-aware ha spliciato barre Binance-spot dentro la serie Deribit-perp:
il crollo del backtest potrebbe (a) rivelare la verita' (l'edge era print) o (b) essere
un artefatto dello splice (basis perp/spot ai punti di giunzione). Test decisivo:
girare lo STESSO engine fade su una serie 100% Binance spot (sorgente coerente, niente
splice). Se anche qui la fade e' negativa -> edge confermato finto.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np, pandas as pd, ccxt
from scripts.analysis.risk_management import build_trades, strats_for
EX = ccxt.binance({"enableRateLimit": True})
SYM = {"BTC": "BTC/USDT", "ETH": "ETH/USDT"}
START = "2020-06-01" # warmup per EMA200/ATR; il report usa 2021+
YEARS = [2021, 2022, 2023, 2024, 2025, 2026]
def fetch(asset, tf="15m"):
start_ms = int(pd.Timestamp(START, tz="UTC").timestamp() * 1000)
end_ms = int(pd.Timestamp("2026-05-26", tz="UTC").timestamp() * 1000)
tf_ms = 15 * 60 * 1000
rows = []
since = start_ms
while since <= end_ms:
r = EX.fetch_ohlcv(SYM[asset], tf, since=since, limit=1000)
if not r:
break
rows += r
nxt = int(r[-1][0]) + tf_ms
if nxt <= since:
break
since = nxt
df = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close", "volume"])
df = df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
return df[df["timestamp"] <= end_ms]
def yearly(rows_by_year_ret, ts, trades, pos=0.15):
# per-anno compound
yr = {y: 1000.0 for y in YEARS}
# ricostruisco compound per anno separato (reset capitale ogni anno per ret% annuo)
by = {y: [] for y in YEARS}
for i, j, r in trades:
y = ts.iloc[i].year
if y in by:
by[y].append(r)
out = {}
for y in YEARS:
cap = 1000.0
for r in by[y]:
cap = max(cap + cap * pos * r, 10.0)
out[y] = (cap / 1000 - 1) * 100
return out
def full_oos(ts, trades, pos=0.15, split_date="2024-10-12"):
sd = pd.Timestamp(split_date, tz="UTC")
def comp(sub):
cap = 1000.0; rets = []
for i, j, r in sub:
cap = max(cap + cap * pos * r, 10.0); rets.append(r * pos)
return cap, rets
capF, rF = comp(trades)
oos = [(i, j, r) for i, j, r in trades if ts.iloc[i] >= sd]
capO, rO = comp(oos)
shF = float(np.mean(rF) / np.std(rF) * np.sqrt(len(rF))) if len(rF) > 1 and np.std(rF) > 0 else 0.0
shO = float(np.mean(rO) / np.std(rO) * np.sqrt(len(rO))) if len(rO) > 1 and np.std(rO) > 0 else 0.0
return (capF / 1000 - 1) * 100, shF, (capO / 1000 - 1) * 100, shO
def main():
print(f"Fetch Binance 15m (da {START})...\n")
data = {a: fetch(a) for a in ("BTC", "ETH")}
print("=" * 92)
print(" FADE su PURO Binance spot 15m | RET% per anno (pos 0.15, leva 3x, trend 3.0)")
print("=" * 92)
print(f" {'sleeve':<12s}" + "".join(f"{y:>9d}" for y in YEARS) + " | FULL% Shrp | OOS% Shrp")
print(" " + "-" * 88)
for asset in ("BTC", "ETH"):
df = data[asset].copy()
df = df[pd.to_datetime(df["timestamp"], unit="ms", utc=True).dt.year >= 2021].reset_index(drop=True) \
if False else df # tengo il warmup, filtro nei trade
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
for code in ("MR01", "MR02", "MR07"):
fn, params = strats_for(asset)[code]
trades = build_trades(fn(df, **params), df, trend_max=3.0)
trades = [(i, j, r) for i, j, r in trades if ts.iloc[i].year >= 2021]
yr = yearly(None, ts, trades)
fF, shF, fO, shO = full_oos(ts, trades)
print(f" {code+'_'+asset:<12s}" + "".join(f"{yr[y]:>+9.0f}" for y in YEARS) +
f" | {fF:>+8.0f} {shF:>5.2f} | {fO:>+6.0f} {shO:>5.2f}")
if __name__ == "__main__":
main()
@@ -0,0 +1,144 @@
"""Validazione dell'edge del credit-spread di cerbero-bite sui PREZZI REALI.
cerbero-bite (container accanto) vende credit spread su ETH (bull-put primario,
short delta ~0.18, DTE 18, PT 50% / stop 2.5x credito / delta-breach 0.30 / vol-stop
+10 DVOL / time-stop 7 DTE). Domanda: l'edge regge su un CICLO ETH completo, o e'
profittevole solo nei campioni calmi?
Tre analisi (riprendibili):
1) entry_economics() -> economia d'ingresso REALE dalla chain (data/options/eth_chain.parquet):
credit/width effettivo a delta 0.18 dai bid/ask veri, eleggibilita' sotto i gate liquidita'.
2) tail_model_free() -> esito terminale dai prezzi ETH reali (2018-2026), cw reale 0.106,
NESSUN modello opzioni (niente errore BS): win-rate, EV, frequenza max-loss.
3) managed_backtest() -> lifecycle CON management; mark con skew calibrato sulle IV reali.
ESITO (2026-06-09):
- cw reale a delta 0.18 = 0.106 (short ~9.4% OTM, NON 18%), max-loss/credito = 8.4x, eleggibilita' 65%.
- hold-to-expiry @0.106: EV -1.0 crediti/trade, 7/9 anni NEGATIVI, max-loss 17.8% delle volte.
- managed (skew): EV -0.02 cr/trade, win-rate 37% (delta-breach esce sul 62% dei trade a piccola perdita).
- VERDETTO: NON edge robusto su ciclo completo. Il "+0.48%/mese" era artefatto di finestra calma
(mag-giu 2026, no crash). Premium-selling a skew negativo: vince nei campioni calmi, restituisce
tutto (o piu') nei crash. Tune "Profilo B" (vendere a 9.4% OTM) PEGGIORA la frequenza di max-loss.
Coda CONCENTRATA col fade ETH di PythagorasGoal (stesso crash colpisce entrambi).
TODO APERTO (per nail-are l'EV managed esatto): la calibrazione non e' ancora perfetta
(mark mid+skew da cw 0.228 vs 0.106 reale -> sovrastima il credito ~2x). Manca: modellare
bid/ask reale incrociato sulle 2 gambe + griglia strike reale (entrambi nella chain) cosi'
l'entry cw scende a 0.106 e l'EV managed diventa esatto. Allora chiudere il sì/no definitivo.
uv run python scripts/analysis/cerbero_bite_credit_spread.py
"""
from __future__ import annotations
import sys, math, collections
from pathlib import Path
import numpy as np, pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.options_chain import OptionChain, load_market
from scripts.analysis.explore_lab import get_df
from scripts.analysis.option_overlay_lab import bs_put, _ncdf, dvol_for
SHORT_OTM, LONG_OTM, DTE = 0.094, 0.134, 17 # da chain reale (delta 0.18, width 4%)
CW_REAL = 0.106
def entry_economics():
oc = OptionChain("ETH"); ch = oc.df
mk = load_market("ETH")[["ts_ms", "spot"]].dropna().sort_values("ts_ms")
p = ch[ch["option_type"] == "P"].copy()
p = pd.merge_asof(p.sort_values("ts_ms"), mk, on="ts_ms", direction="backward")
cand = p[(p["tenor_d"] >= 14) & (p["tenor_d"] <= 21)].dropna(subset=["delta", "bid", "ask", "strike", "spot"])
rows = []
for (ts, exp), g in cand.groupby(["timestamp", "expiry"]):
spot = g["spot"].iloc[0]
sc = g[(g["delta"] <= -0.12) & (g["delta"] >= -0.22)]
if sc.empty: continue
short = sc.iloc[(sc["delta"] + 0.18).abs().argmin()]
Ks = short["strike"]; longc = g[g["strike"] < Ks]
if longc.empty: continue
longp = longc.iloc[(longc["strike"] - (Ks - spot * 0.04)).abs().argmin()]
W = Ks - longp["strike"]
if W <= 0: continue
credit = short["bid"] - longp["ask"]
def ok(o):
sp = (o["ask"] - o["bid"]) / ((o["ask"] + o["bid"]) / 2) if (o["ask"] + o["bid"]) > 0 else 9
return (o["open_interest"] or 0) >= 100 and sp <= 0.15 and o["bid"] > 0
cw = credit / (W / spot)
rows.append(dict(cw=cw, credit=credit, elig=ok(short) and ok(longp) and cw >= 0.08 and credit > 0,
short_otm=(spot - Ks) / spot, delta=short["delta"]))
r = pd.DataFrame(rows)
print(f"[ENTRY] {len(r)} spread | eleggibili {r['elig'].mean()*100:.0f}% | cw mediano {r['cw'].median():.3f} "
f"| short OTM {r['short_otm'].median()*100:.1f}% | max-loss/credito {((1-r['cw'].median())/r['cw'].median()):.1f}x")
def tail_model_free():
df = get_df("ETH", "1h"); c = df["close"].values; n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True); H = DTE * 24
res = []
for i in range(200, n - H - 1, 24 * 2):
S0 = c[i]; Ks = S0 * (1 - SHORT_OTM); Kl = S0 * (1 - LONG_OTM); W = Ks - Kl
Sx = c[i + H]; intr = min(max(Ks - Sx, 0.0), W); credit = CW_REAL * W
res.append((ts.iloc[i].year, 1 - intr / credit, Sx < Kl))
R = pd.DataFrame(res, columns=["y", "pnl", "maxloss"]); P = R["pnl"].values
print(f"[TAIL model-free @cw0.106] win {(P>0).mean()*100:.0f}% | EV {P.mean():+.2f}cr | max-loss {R['maxloss'].mean()*100:.0f}% "
f"| anni neg {(R.groupby('y')['pnl'].mean()<0).sum()}/{R['y'].nunique()}")
def _skew_fit():
oc = OptionChain("ETH"); ch = oc.df
mk = load_market("ETH")[["ts_ms", "spot"]].dropna().sort_values("ts_ms")
p = ch[ch["option_type"] == "P"].copy()
p = pd.merge_asof(p.sort_values("ts_ms"), mk, on="ts_ms", direction="backward")
p = p.dropna(subset=["iv", "strike", "spot", "delta", "tenor_d"])
p = p[(p["tenor_d"] >= 7) & (p["tenor_d"] <= 35) & (p["iv"] > 0)]
p["dd"] = (p["delta"] + 0.5).abs()
atm = p.sort_values("dd").groupby("timestamp")["iv"].first()
p["atm_iv"] = p["timestamp"].map(atm); p = p.dropna(subset=["atm_iv"])
p["k"] = np.log(p["strike"] / p["spot"]); p["ratio"] = p["iv"] / p["atm_iv"]
p = p[(p["k"] > -0.35) & (p["k"] < 0.15) & (p["ratio"] > 0.5) & (p["ratio"] < 3)]
coef, *_ = np.linalg.lstsq(np.c_[p["k"], p["k"]**2], p["ratio"] - 1.0, rcond=None)
return coef # a, b
def managed_backtest():
a, b = _skew_fit()
def ivol(S, K, atm):
k = math.log(K / S); return max(atm * (1 + a * k + b * k * k), 0.05)
def put_delta(S, K, T, sig):
if T <= 0 or sig <= 0: return -1.0 if S < K else 0.0
return _ncdf((math.log(S / K) + 0.5 * sig * sig * T) / (sig * math.sqrt(T))) - 1.0
def mark(S, Ks, Kl, T, atm):
return bs_put(S, Ks, T, ivol(S, Ks, atm)) - bs_put(S, Kl, T, ivol(S, Kl, atm))
df = get_df("ETH", "1h"); c = df["close"].values; n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True); dvol = dvol_for(df, "ETH")
H = DTE * 24; STEP = 6; cw = []; tr = []
for i in range(200, n - H - 1, 24 * 2):
S0 = c[i]; atm0 = dvol[i] if not np.isnan(dvol[i]) else 0.6
Ks = S0 * (1 - SHORT_OTM); Kl = S0 * (1 - LONG_OTM); W = Ks - Kl
credit = mark(S0, Ks, Kl, DTE / 365.0, atm0)
if credit <= 0: continue
cw.append(credit / W); pnl = why = None
for k in range(STEP, H + 1, STEP):
j = i + k; Trem = max((H - k) / (24 * 365.0), 1e-6); Sj = c[j]
atmj = dvol[j] if not np.isnan(dvol[j]) else atm0; mk = mark(Sj, Ks, Kl, Trem, atmj)
if mk <= 0.5 * credit: pnl, why = 1 - mk / credit, "PT"; break
if mk >= 2.5 * credit: pnl, why = 1 - mk / credit, "stop"; break
if put_delta(Sj, Ks, Trem, ivol(Sj, Ks, atmj)) <= -0.30: pnl, why = 1 - mk / credit, "delta"; break
if atmj - atm0 >= 0.10: pnl, why = 1 - mk / credit, "vol"; break
if k >= (DTE - 7) * 24: pnl, why = 1 - mk / credit, "time"; break
if pnl is None:
Sx = c[i + H]; intr = min(max(Ks - Sx, 0), W); pnl, why = 1 - intr / credit, "expiry"
tr.append((ts.iloc[i].year, pnl, why))
P = np.array([t[1] for t in tr])
print(f"[MANAGED skew] cw@entry {np.median(cw):.3f} (vs 0.106 reale: sovrastima ~2x, EV vero <=) | "
f"win {(P>0).mean()*100:.0f}% | EV {P.mean():+.3f}cr | worst {P.min():.1f} | "
f"uscite {dict(collections.Counter(t[2] for t in tr))}")
R = pd.DataFrame({"y": [t[0] for t in tr], "p": P})
print(f" 2021+: EV {R[R.y>=2021]['p'].mean():+.3f}cr/trade")
if __name__ == "__main__":
entry_economics()
tail_model_free()
managed_backtest()
+77
View File
@@ -0,0 +1,77 @@
"""PROBE CERBERO MCP — quali exchange/fonti serve davvero? (cerca IBKR & alt)
Non si fida del commento nel codice: interroga il server v2 (/mcp/tools/get_historical
con `exchange=...`) su una matrice di nomi exchange + naming strumento, e riporta chi
risponde con candele vere. Cerca in particolare IBKR e Alpaca (spot US reale).
uv run python scripts/analysis/cerbero_probe.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import requests
from src.live.cerbero_client import CerberoClient, is_mainnet
C = CerberoClient()
START, END, INTERVAL = "2026-05-20", "2026-05-27", "1h"
# exchange -> naming strumento BTC da provare (varianti)
EXCHANGES = {
"deribit": ["BTC-PERPETUAL"],
"hyperliquid": ["BTC"],
"bybit": ["BTCUSDT", "BTC-PERPETUAL", "BTCUSD"],
"alpaca": ["BTC/USD", "BTCUSD", "BTC/USDT", "BTC"],
"ibkr": ["BTC", "BTC.USD", "BTCUSD"],
"interactivebrokers": ["BTC", "BTCUSD"],
"binance": ["BTCUSDT", "BTC/USDT", "BTC-PERPETUAL"],
"coinbase": ["BTC-USD", "BTC/USD", "BTCUSD"],
"kraken": ["XBTUSD", "BTC/USD", "BTCUSD"],
"okx": ["BTC-USDT", "BTC-USD-SWAP"],
}
def try_v2(exchange: str, instrument: str) -> str:
try:
candles = C.get_historical_v2(instrument, START, END, INTERVAL, exchange=exchange)
if candles:
c0, c1 = candles[0], candles[-1]
return f"OK {len(candles):>4} candele close {c1.get('close')}"
return "vuoto (0 candele)"
except requests.HTTPError as e:
code = e.response.status_code if e.response is not None else "?"
return f"HTTP {code}"
except Exception as e:
return f"{type(e).__name__}: {str(e)[:50]}"
def list_tools() -> None:
"""Tenta di enumerare i tool/endpoint del server (best-effort)."""
for path in ("/mcp/tools", "/mcp/tools/list", "/tools", "/mcp"):
try:
r = requests.post(f"{C.base_url}{path}", headers=C._headers(), json={}, timeout=10)
print(f" POST {path:<18} -> HTTP {r.status_code} {str(r.text)[:200]}")
except Exception as e:
print(f" POST {path:<18} -> {type(e).__name__}: {str(e)[:60]}")
def main():
print("=" * 80)
print(f" PROBE CERBERO MCP @ {C.base_url} (mainnet={is_mainnet()})")
print("=" * 80)
print("\n[1] Enumerazione endpoint/tool (best-effort):")
list_tools()
print(f"\n[2] get_historical_v2 BTC {INTERVAL} {START}->{END} per exchange:")
print(f" {'exchange':<20s}{'instrument':<16s}esito")
print(" " + "-" * 70)
for ex, syms in EXCHANGES.items():
for sym in syms:
res = try_v2(ex, sym)
print(f" {ex:<20s}{sym:<16s}{res}")
if res.startswith("OK"):
break # trovato il naming giusto, basta
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""RI-ESECUZIONE FADE sul feed PULITO (data/raw ricostruito da Deribit mainnet, 2026-06-19).
Dopo il rebuild (scripts/analysis/rebuild_history.py) i parquet canonici in data/raw
sono storia Deribit mainnet reale (ccxt pubblico), certificata vs Coinbase USD. Qui giro
le 6 fade (MR01/MR02/MR07 x BTC/ETH) con l'ENGINE CANONICO (risk_management.build_trades,
strats_for) sul feed pulito, su ENTRAMBI i timeframe:
- 1h = config dei claim storici "validati OOS" (CLAUDE.md: MR01 BTC +201% / ETH +1238%)
- 15m = config LIVE attuale (swap 1h->15m, v1.1.30)
Stessi parametri del live: pos 0.15, leva 3x, trend_max 3.0, fee 0.10% RT. OOS = ultimo 30%
per indice (convenzione OOS_FRAC del progetto). Read-only, nessuna scrittura.
uv run python scripts/analysis/clean_fade_rerun.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from src.data.downloader import load_data
from scripts.analysis.risk_management import strats_for, build_trades, POS, LEV, OOS_FRAC
TFS = ["1h", "15m"]
YEARS = list(range(2018, 2027))
def metrics(ts, trades, split_idx, pos=POS):
"""trades = [(i, j, r_netto)]. Ritorna (per-anno%, FULL%, FULL Sharpe, OOS%, OOS Sharpe)."""
by = {y: 0.0 for y in YEARS}
capF = capO = 1000.0
rF, rO = [], []
for i, j, r in trades:
y = ts.iloc[i].year
if y in by:
by[y] += r * pos * 1000.0 # contributo lineare per la riga annuale
capF = max(capF + capF * pos * r, 10.0)
rF.append(r * pos)
if i >= split_idx:
capO = max(capO + capO * pos * r, 10.0)
rO.append(r * pos)
yr = {y: by[y] / 1000.0 * 100 for y in YEARS}
shF = float(np.mean(rF) / np.std(rF) * np.sqrt(len(rF))) if len(rF) > 1 and np.std(rF) > 0 else 0.0
shO = float(np.mean(rO) / np.std(rO) * np.sqrt(len(rO))) if len(rO) > 1 and np.std(rO) > 0 else 0.0
return yr, (capF / 1000 - 1) * 100, shF, (capO / 1000 - 1) * 100, shO
def main():
years_present = set()
results = {}
for tf in TFS:
for asset in ("BTC", "ETH"):
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
years_present |= set(ts.dt.year.unique().tolist())
split_idx = int(len(df) * (1 - OOS_FRAC))
cov = f"{ts.iloc[0].date()} -> {ts.iloc[-1].date()} ({len(df)} barre, OOS da {ts.iloc[split_idx].date()})"
for code in ("MR01", "MR02", "MR07"):
fn, params = strats_for(asset)[code]
trades = build_trades(fn(df, **params), df, trend_max=3.0)
results[(tf, asset, code)] = (metrics(ts, trades, split_idx), len(trades), cov)
years = [y for y in YEARS if y in years_present]
for tf in TFS:
print("\n" + "=" * (62 + 9 * len(years)))
print(f" FADE su FEED PULITO (Deribit mainnet) — {tf} | pos {POS}, leva {LEV:.0f}x, trend 3.0, fee 0.10% RT")
# mostra la copertura una volta per asset
for asset in ("BTC", "ETH"):
print(f" {asset}: {results[(tf, asset, 'MR01')][2]}")
print("=" * (62 + 9 * len(years)))
print(f" {'sleeve':<11s}" + "".join(f"{y:>9d}" for y in years) +
f"{'Trd':>7s}{'FULL%':>9s}{'Shrp':>7s}{'OOS%':>8s}{'Shrp':>7s}")
print(" " + "-" * (60 + 9 * len(years)))
for asset in ("BTC", "ETH"):
for code in ("MR01", "MR02", "MR07"):
(yr, fF, shF, fO, shO), ntr, _ = results[(tf, asset, code)]
print(f" {code+'_'+asset:<11s}" + "".join(f"{yr[y]:>+9.0f}" for y in years) +
f"{ntr:>7d}{fF:>+9.0f}{shF:>7.2f}{fO:>+8.0f}{shO:>7.2f}")
print()
if __name__ == "__main__":
main()
+274
View File
@@ -0,0 +1,274 @@
"""CLEAN FEED — ripara gli spike-print del feed Deribit/Cerbero coi dati reali di Binance.
Motivo (2026-06-18): la ricerca Price Ladder ha rivelato che data/raw/btc_1h.parquet (e gli
altri TF/asset) contengono barre con WICK FASULLI (es. BTC 2024-02-13: low 38.580 con
close ~49.968, BTC reale ~50k) — lo stesso spike-print testnet documentato in CLAUDE.md
(TP_PHANTOM / feed congelato). Sono pochi (decine per file) ma avvelenano i backtest
(stop/entry su prezzi mai avvenuti) e gonfiano le code (la "FULL DD BTC ~54%" del ladder era
in gran parte questo).
Metodo (conservativo, fonte di verita' = Binance spot via ccxt, gia' cablato nel progetto):
1. DETECT: barra sospetta = high/low che sfora >15% il cluster di close locale [i-1,i,i+1]
(close sano + wick fasullo). Soglia larga: tanto e' Binance ad arbitrare.
2. ARBITRA: per ogni sospetta, scarica la barra Binance reale (BTC/USDT, ETH/USDT) allo
stesso tf/timestamp. Sostituisce O/H/L/C SOLO se Binance dissente materialmente (>2% su
high o low) -> un wick VERO confermato da Binance resta intatto. Volume/timestamp invariati.
3. BACKUP (data/_feed_backup/) + scrittura atomica + VALIDAZIONE (re-scan = 0 sospette,
n righe invariato). Log dettagliato di ogni barra riparata (old OHLC -> new).
uv run python scripts/analysis/clean_feed.py [ASSET_TF ...] # default: tutti BTC/ETH x TF
uv run python scripts/analysis/clean_feed.py BTC_1h # un solo file
"""
from __future__ import annotations
import shutil
import sys
import time
from pathlib import Path
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 _parquet_path, DATA_DIR
BACKUP = PROJECT_ROOT / "data" / "_feed_backup"
SYMBOL = {"BTC": "BTC/USDT", "ETH": "ETH/USDT"}
WICK_THR = 0.15 # detect: wick oltre 15% il cluster di close locale
REPLACE_THR = 0.02 # arbitra: sostituisci solo se Binance dissente >2% su high/low
CLOSE_THR = 0.01 # close-aware: sostituisci la barra se il CLOSE Deribit dista >1% da Binance
TF_MS = {"5m": 5, "8m": 8, "13m": 13, "15m": 15, "19m": 19, "30m": 30, "1h": 60}
_EX = None
def _binance():
global _EX
if _EX is None:
import ccxt
_EX = ccxt.binance({"enableRateLimit": True})
return _EX
def suspect_mask(df: pd.DataFrame) -> np.ndarray:
c = df["close"].to_numpy(float); h = df["high"].to_numpy(float); l = df["low"].to_numpy(float)
cp = np.roll(c, 1); cp[0] = c[0]; cn = np.roll(c, -1); cn[-1] = c[-1]
locmax = np.maximum.reduce([c, cp, cn]); locmin = np.minimum.reduce([c, cp, cn])
return (h > locmax * (1 + WICK_THR)) | (l < locmin * (1 - WICK_THR))
def _binance_bar(symbol: str, tf: str, ts_ms: int):
"""OHLC reale Binance alla barra ts_ms (None se assente)."""
try:
rows = _binance().fetch_ohlcv(symbol, tf, since=ts_ms - 1, limit=3)
except Exception as e:
print(f" ! binance err: {type(e).__name__}: {str(e)[:80]}")
return None
for r in rows:
if int(r[0]) == ts_ms:
return float(r[1]), float(r[2]), float(r[3]), float(r[4])
return None
def clean_file(asset: str, tf: str) -> dict:
path = _parquet_path(asset, tf)
if not path.exists():
return {"file": f"{asset}_{tf}", "skip": "no-file"}
df = pd.read_parquet(path)
mask = suspect_mask(df)
idx = np.where(mask)[0]
n0 = len(df)
if len(idx) == 0:
return {"file": f"{asset}_{tf}", "suspect": 0, "repaired": 0, "kept_real": 0,
"missing_binance": 0, "rows_before": n0, "rows_after": n0,
"still_suspect": 0, "log": []}
repaired, kept, missing = 0, 0, 0
log = []
for i in idx:
ts = int(df.iloc[i]["timestamp"])
b = _binance_bar(SYMBOL[asset], tf, ts)
oh, ol = float(df.iloc[i]["high"]), float(df.iloc[i]["low"])
if b is None:
missing += 1
continue
bo, bh, bl, bc = b
if abs(oh - bh) / bh > REPLACE_THR or abs(ol - bl) / max(bl, 1e-9) > REPLACE_THR:
df.iat[i, df.columns.get_loc("open")] = bo
df.iat[i, df.columns.get_loc("high")] = bh
df.iat[i, df.columns.get_loc("low")] = bl
df.iat[i, df.columns.get_loc("close")] = bc
repaired += 1
ts_s = pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
log.append(f" {ts_s} H {oh:,.0f}->{bh:,.0f} L {ol:,.0f}->{bl:,.0f}")
else:
kept += 1 # Binance conferma il wick: barra reale, intatta
if repaired:
BACKUP.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, BACKUP / f"{asset.lower()}_{tf}.parquet.bak")
tmp = path.with_suffix(".parquet.tmp")
df.to_parquet(tmp, index=False)
tmp.replace(path)
# validazione
df2 = pd.read_parquet(path)
still = int(suspect_mask(df2).sum())
return {"file": f"{asset}_{tf}", "suspect": len(idx), "repaired": repaired,
"kept_real": kept, "missing_binance": missing, "rows_before": n0,
"rows_after": len(df2), "still_suspect": still, "log": log}
def _binance_series(asset: str, tf: str, start_ms: int, end_ms: int) -> dict:
"""OHLC reale Binance per l'intero range -> dict ts_ms -> (o,h,l,c). Bulk paginato."""
ex = _binance()
tf_ms = TF_MS[tf] * 60 * 1000
out: dict[int, tuple] = {}
since = start_ms
while since <= end_ms:
try:
rows = ex.fetch_ohlcv(SYMBOL[asset], tf, since=since, limit=1000)
except Exception as e:
print(f" ! binance err: {type(e).__name__}: {str(e)[:80]}")
break
if not rows:
break
for r in rows:
out[int(r[0])] = (float(r[1]), float(r[2]), float(r[3]), float(r[4]))
nxt = int(rows[-1][0]) + tf_ms
if nxt <= since:
break
since = nxt
if len(rows) < 1000 and since > end_ms:
break
return out
def clean_file_close(asset: str, tf: str, thr: float = CLOSE_THR, backup_dir: Path | None = None) -> dict:
"""CLOSE-AWARE: sostituisce O/H/L/C con Binance per ogni barra il cui CLOSE Deribit
dista > thr da Binance (1% default). Cattura i print 'silenziosi' che il wick-check
>15% non vede (close fantasma su barra di range piccolo). Fonte di verita' = Binance
spot (il feed storico e' perp testnet -> inaffidabile; lo spot ~ mainnet via arbitraggio)."""
if tf not in TF_MS:
return {"file": f"{asset}_{tf}", "skip": "tf-non-binance"}
path = _parquet_path(asset, tf)
if not path.exists():
return {"file": f"{asset}_{tf}", "skip": "no-file"}
df = pd.read_parquet(path)
n0 = len(df)
tms = df["timestamp"].to_numpy("int64")
c = df["close"].to_numpy(float)
bz = _binance_series(asset, tf, int(tms[0]), int(tms[-1]))
col = {k: df.columns.get_loc(k) for k in ("open", "high", "low", "close")}
fixed, by_year, missing = 0, {}, 0
log = []
for i in range(n0):
b = bz.get(int(tms[i]))
if b is None:
missing += 1
continue
bo, bh, bl, bc = b
if bc <= 0:
continue
orig = float(c[i]) # cattura PRIMA della scrittura (to_numpy puo' essere una view)
if abs(orig - bc) / bc > thr:
df.iat[i, col["open"]] = bo
df.iat[i, col["high"]] = bh
df.iat[i, col["low"]] = bl
df.iat[i, col["close"]] = bc
fixed += 1
y = pd.to_datetime(int(tms[i]), unit="ms", utc=True).year
by_year[y] = by_year.get(y, 0) + 1
if len(log) < 10:
ts_s = pd.to_datetime(int(tms[i]), unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
log.append(f" {ts_s} C {orig:,.2f}->{bc:,.2f} ({abs(orig-bc)/bc*100:.1f}%)")
if fixed:
bdir = backup_dir or BACKUP
bdir.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, bdir / f"{asset.lower()}_{tf}.parquet.bak")
tmp = path.with_suffix(".parquet.tmp")
df.to_parquet(tmp, index=False)
tmp.replace(path)
# validazione: ri-scan, 0 barre residue oltre soglia (fra quelle coperte da Binance)
df2 = pd.read_parquet(path)
c2 = df2["close"].to_numpy(float)
still = sum(1 for i in range(len(df2))
if (b := bz.get(int(tms[i]))) and b[3] > 0 and abs(c2[i] - b[3]) / b[3] > thr)
return {"file": f"{asset}_{tf}", "covered": n0 - missing, "fixed": fixed,
"missing_binance": missing, "rows_before": n0, "rows_after": len(df2),
"still_over_thr": still, "by_year": by_year, "log": log}
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
close_mode = "--close" in sys.argv
dry = "--dry" in sys.argv
if close_mode:
targets = args or [f"{a}_{tf}" for a in ("BTC", "ETH") for tf in ("5m", "15m", "1h")]
stamp = time.strftime("%Y%m%d-%H%M%S")
bdir = BACKUP / f"pre_close_clean_{stamp}"
print(f"CLEAN FEED (close-aware vs Binance, thr={CLOSE_THR*100:.0f}%) — "
f"{'DRY-RUN (nessuna scrittura)' if dry else f'backup in {bdir}'}\n")
grand = 0
for t in targets:
asset, tf = t.split("_", 1)
if dry:
# dry: conta soltanto, niente scrittura
r = _dry_close(asset, tf)
else:
r = clean_file_close(asset, tf, backup_dir=bdir)
if r.get("skip"):
print(f" {t:<9} SKIP ({r['skip']})"); continue
grand += r.get("fixed", 0)
yr = " ".join(f"{y}:{n}" for y, n in sorted(r.get("by_year", {}).items()))
print(f" {r['file']:<9} coperte={r.get('covered',0):>7} riparate={r.get('fixed',0):>4} "
f"no-binance={r.get('missing_binance',0):>5} | righe {r['rows_before']}=={r.get('rows_after',r['rows_before'])} "
f"residue>thr={r.get('still_over_thr','-')}")
if yr:
print(f" per anno: {yr}")
for line in r.get("log", []):
print(line)
print(f"\n TOTALE barre riparate (close-aware): {grand}")
return
targets = args or [f"{a}_{tf}" for a in ("BTC", "ETH") for tf in ("5m", "15m", "30m", "1h")]
print(f"CLEAN FEED — backup in {BACKUP}\n")
grand = 0
for t in targets:
asset, tf = t.split("_", 1)
r = clean_file(asset, tf)
if r.get("skip"):
print(f" {t:<9} SKIP ({r['skip']})"); continue
grand += r.get("repaired", 0)
print(f" {r['file']:<9} sospette={r['suspect']:>3} riparate={r['repaired']:>3} "
f"reali-tenute={r.get('kept_real',0):>3} no-binance={r.get('missing_binance',0):>2} "
f"| righe {r['rows_before']}=={r['rows_after']} residue-sospette={r['still_suspect']}")
for line in r.get("log", [])[:8]:
print(line)
if len(r.get("log", [])) > 8:
print(f" ... (+{len(r['log'])-8} altre)")
print(f"\n TOTALE barre riparate: {grand}")
def _dry_close(asset: str, tf: str, thr: float = CLOSE_THR) -> dict:
"""Conta soltanto quante barre verrebbero riparate (nessuna scrittura)."""
if tf not in TF_MS:
return {"file": f"{asset}_{tf}", "skip": "tf-non-binance"}
path = _parquet_path(asset, tf)
if not path.exists():
return {"file": f"{asset}_{tf}", "skip": "no-file"}
df = pd.read_parquet(path)
tms = df["timestamp"].to_numpy("int64"); c = df["close"].to_numpy(float)
bz = _binance_series(asset, tf, int(tms[0]), int(tms[-1]))
fixed, by_year, missing = 0, {}, 0
for i in range(len(df)):
b = bz.get(int(tms[i]))
if b is None:
missing += 1; continue
if b[3] > 0 and abs(c[i] - b[3]) / b[3] > thr:
fixed += 1
y = pd.to_datetime(int(tms[i]), unit="ms", utc=True).year
by_year[y] = by_year.get(y, 0) + 1
return {"file": f"{asset}_{tf}", "covered": len(df) - missing, "fixed": fixed,
"missing_binance": missing, "rows_before": len(df), "by_year": by_year, "log": []}
if __name__ == "__main__":
main()
+164
View File
@@ -0,0 +1,164 @@
"""Studio: combinare TUTTE le strategie (fade + honest) migliora i risultati?
Due famiglie con meccanismi e orizzonti diversi:
FADE (intraday 1h, long/short, BTC/ETH): MR01 boll, MR02 donchian, MR07
return-reversal — tutte col filtro trend 3.0 ATR. (MR03 keltner -> waste.)
HONEST (long-only, multi-regime, multi-crypto): DIP01 (dip-buy 1h BTC),
TR01 (EMA trend 4h basket), ROT02 (dual-momentum rotation 1d).
Metodo: per ogni sleeve si costruisce l'equity GIORNALIERA normalizzata su un
indice comune (2021-01-01 -> 2026-05-26), si passa ai rendimenti giornalieri,
si misura la correlazione cross-famiglia e si confrontano i portafogli
equal-weight (ribilanciati ogni giorno) e inverse-vol. Metriche FULL e OOS
(ultimo 30% della finestra comune): ritorno, CAGR, max DD, Sharpe annualizzato.
Tutto NETTO (fee gia' incluse nelle sleeve), leva 3x, pos 15% per sleeve.
"""
from __future__ import annotations
import sys
from pathlib import Path
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.analysis.risk_management import strats_for, build_trades, INIT
# curve daily honest gia' pronte nell'altra famiglia
from scripts.analysis.honest_improve2 import (
_daily_equity, _norm, dip_market_gated, _tr_basket_daily, _rot_daily_equity,
)
IDX = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
OOS_FRAC = 0.30
SPLIT = int(len(IDX) * (1 - OOS_FRAC)) # confine OOS sulla finestra comune
OOS_DATE = IDX[SPLIT].date()
ANN = 365.0 # giorni/anno per annualizzare
# ---------------- equity giornaliere ----------------
# SWAP fade 1h -> 15m (2026-06-12, scelta utente). Gate fade15m_port06_gate.py: parametri
# 1h NON ri-tunati trasferiti a 15m, corr 15m-1h media 0.26, SWAP promosso (FULL CAGR
# 74->101% DD 3.46->2.47%, OOS Sh 10.07->10.86; OOS DD 1.48->2.09 accettato), edge ETH
# regge il flat-entry-skip. Il canonico segue il deployato per tenere la parita' delle
# due facce. Diario docs/diary/2026-06-12-fade15m-gate.md.
FADE_TF = "15m"
def fade_daily_equity(asset: str, fn, params, tf: str = FADE_TF) -> pd.Series:
"""Equity giornaliera di uno sleeve fade: trade (filtro trend 3.0) -> equity -> daily."""
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
trades = build_trades(fn(df, **params), df, trend_max=3.0)
n = len(df); eq = np.full(n, INIT, dtype=float); cap = INIT
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * 0.15 * ret, 10.0)
eq[j:] = cap
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
def build_all_sleeves() -> dict[str, pd.Series]:
sleeves: dict[str, pd.Series] = {}
# --- FADE: 6 sleeve (15m dal 2026-06-12, vedi FADE_TF) ---
for asset in ["BTC", "ETH"]:
for nm, (fn, params) in strats_for(asset).items():
sleeves[f"{nm}_{asset}"] = fade_daily_equity(asset, fn, params)
# --- HONEST: 3 sleeve (riuso le funzioni dell'altra famiglia) ---
d = dip_market_gated("BTC", market_n=0, return_equity=True)
sleeves["DIP01_BTC"] = _norm(_daily_equity(d["eq_ts"], d["eq_v"], IDX))
sleeves["TR01_basket"] = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX))
sleeves["ROT02_rot"] = _norm(_rot_daily_equity(IDX))
return sleeves
# ---------------- metriche ----------------
def metrics(daily_ret: pd.Series, lo: int = 0, hi: int | None = None) -> dict:
r = daily_ret.iloc[lo:hi]
eq = (1 + r).cumprod()
peak = eq.cummax(); dd = float(((peak - eq) / peak).max() * 100)
yrs = len(r) / ANN
tot = (eq.iloc[-1] - 1) * 100
cagr = ((eq.iloc[-1]) ** (1 / yrs) - 1) * 100 if yrs > 0 else 0.0
sharpe = float(r.mean() / r.std() * np.sqrt(ANN)) if r.std() > 0 else 0.0
return dict(ret=tot, cagr=cagr, dd=dd, sharpe=sharpe)
def yearly_returns(daily_ret: pd.Series) -> dict[int, float]:
"""Rendimento % netto per anno solare dai rendimenti giornalieri composti."""
g = daily_ret.groupby(daily_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100)
return {int(y): float(v) for y, v in g.items()}
def port_returns(members: dict[str, pd.Series], weights: dict[str, float] | None = None) -> pd.Series:
"""Rendimenti giornalieri di un portafoglio ribilanciato ogni giorno ai pesi dati."""
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in members.items()})
if weights is None:
return dr.mean(axis=1)
w = pd.Series(weights); w = w / w.sum()
return (dr * w).sum(axis=1)
def inv_vol_weights(members: dict[str, pd.Series], lo=0, hi=None) -> dict[str, float]:
"""Pesi inversamente proporzionali alla volatilita' (stimata sulla finestra train)."""
vol = {k: v.pct_change().iloc[lo:hi].std() for k, v in members.items()}
inv = {k: (1.0 / s if s and s > 0 else 0.0) for k, s in vol.items()}
tot = sum(inv.values())
return {k: x / tot for k, x in inv.items()}
# ---------------- report ----------------
def row(label, dr):
f = metrics(dr); o = metrics(dr, lo=SPLIT)
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['cagr']:>7.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
def main():
print("Costruzione equity giornaliere (puo' richiedere ~1 min)...")
S = build_all_sleeves()
fade = {k: v for k, v in S.items() if k.startswith("MR")}
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
# --- correlazione cross-famiglia ---
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in S.items()})
corr = dr.corr()
fade_k, hon_k = list(fade), list(honest)
cross = corr.loc[fade_k, hon_k]
print("\n" + "=" * 92)
print(f" CORRELAZIONE rendimenti giornalieri — FADE (righe) vs HONEST (colonne) | {IDX[0].date()}->{IDX[-1].date()}")
print("=" * 92)
print(f" {'':<12s}" + "".join(f"{c:>13s}" for c in hon_k))
for f in fade_k:
print(f" {f:<12s}" + "".join(f"{cross.loc[f,c]:>13.2f}" for c in hon_k))
intra_fade = corr.loc[fade_k, fade_k].values[np.triu_indices(len(fade_k), 1)].mean()
intra_hon = corr.loc[hon_k, hon_k].values[np.triu_indices(len(hon_k), 1)].mean()
print(f"\n Corr media intra-FADE {intra_fade:+.2f} | intra-HONEST {intra_hon:+.2f} | "
f"cross-famiglia {cross.values.mean():+.2f} (piu' bassa = piu' diversificazione)")
# --- confronto portafogli ---
print("\n" + "=" * 92)
print(f" PORTAFOGLI equal-weight (ribil. giornaliero) | OOS da {OOS_DATE} | leva3x pos15%/sleeve")
print("=" * 92)
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oCAGR':>7s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 88)
row("FADE only (8 sleeve)", port_returns(fade))
row("HONEST only (3 sleeve)", port_returns(honest))
row("ALL equal-weight (11)", port_returns(S))
# 50/50 fra le due famiglie (ogni famiglia equipesata al suo interno)
fr, hr = port_returns(fade), port_returns(honest)
row("ALL 50/50 famiglie", (fr + hr) / 2)
# inverse-vol sul train, applicato a tutti gli 11 sleeve
w = inv_vol_weights(S, lo=0, hi=SPLIT)
row("ALL inverse-vol", port_returns(S, w))
print(" " + "-" * 88)
print(" Sharpe annualizzato sui rendimenti giornalieri. Confronta DD e Sharpe:")
print(" se il combinato ha DD piu' basso e Sharpe piu' alto delle singole famiglie, combinare conviene.")
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Combina i NUOVI edge (pairs + TSM01) col MASTER esistente: migliora il portafoglio?
Aggiunge al MASTER a 9 sleeve (6 fade + 3 honest) due nuove fonti scoperte
nell'esplorazione, poco correlate:
- PAIRS market-neutral (ETH/BTC, LTC/ETH, ADA/ETH) -> corr ~0 col mercato
- TSM01 (TSMOM multi-orizzonte + risk-off) -> corr ~0.53 con ROT02
Misura correlazione delle nuove sleeve vs esistenti e confronta MASTER-9 vs
MASTER-esteso su Ret/CAGR/DD/Sharpe, FULL e OOS (finestra comune 2021-2026).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
)
from scripts.analysis.honest_improve2 import _daily_equity, _norm
from scripts.analysis.pairs_research import pairs_sim
from scripts.analysis.tsmom_research import tsmom_sim
def daily_from(eq_ts, eq_v):
return _norm(_daily_equity(eq_ts, eq_v, IDX))
def main():
print("Costruzione equity (puo' richiedere ~1-2 min)...\n")
S = build_all_sleeves() # 9 sleeve esistenti
# nuove sleeve: i 6 pairs robusti di PR01 + TSM01
from scripts.strategies.PR01_pairs_reversion import PAIRS
new = {}
for a, b, p in PAIRS:
r = pairs_sim(a, b, **p)
new[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
t = tsmom_sim()
new["TSM01"] = daily_from(t["eq_ts"], t["eq_v"])
allS = {**S, **new}
# --- correlazione nuove vs esistenti ---
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in allS.items()})
corr = dr.corr()
old_k = list(S); new_k = list(new)
print("=" * 88)
print(" CORRELAZIONE rendimenti giornalieri — NUOVE (righe) vs media esistenti")
print("=" * 88)
for nk in new_k:
avg = corr.loc[nk, old_k].mean()
mx = corr.loc[nk, old_k].abs().max()
print(f" {nk:<12s} corr media col MASTER-9 = {avg:+.2f} |max| = {mx:.2f}")
# --- confronto portafogli ---
def line(label, members):
pr = port_returns(members)
f, o = metrics(pr), metrics(pr, lo=SPLIT)
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
return pr
print("\n" + "=" * 96)
print(f" MASTER-9 vs MASTER-ESTESO (con pairs+TSM01) | OOS da {OOS_DATE} | equal-weight daily")
print("=" * 96)
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 92)
pairs_only = {k: v for k, v in new.items() if k.startswith('PR_')}
line(f"MASTER-9 (base)", S)
line(f"MASTER +pairs ({len(S)+len(pairs_only)})", {**S, **pairs_only})
line(f"MASTER +TSM01 ({len(S)+1})", {**S, "TSM01": new["TSM01"]})
pr_all = line(f"MASTER-esteso ({len(allS)})", allS)
print(" " + "-" * 92)
pa = yearly_returns(pr_all)
print(" MASTER-esteso per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
print("\n Se il MASTER-esteso ha DD piu' basso e/o Sharpe piu' alto del MASTER-9, le nuove")
print(" famiglie aggiungono valore (diversificazione da fonti scorrelate).")
if __name__ == "__main__":
main()
@@ -0,0 +1,209 @@
"""ANALISI DI IMPATTO (sola lettura, da docs/TODO.md): bug bfill di `_daily_equity`.
IL BUG (scripts/analysis/honest_improve2.py:30):
daily = s.resample("1D").last().reindex(idx).ffill().bfill()
La serie `s` e' a PUNTI-TRADE (un valore di capitale per ogni exit). Il `reindex(idx)`
taglia PRIMA di forward-fillare: i giorni di IDX precedenti al primo trade DENTRO la
finestra restano NaN (il ffill non ha un valore precedente in-finestra da propagare) e
il `.bfill()` finale li riempie col capitale DOPO il primo trade in-finestra. Effetti:
1. l'ancora a idx[0] e' il capitale post-primo-trade-in-finestra, NON il capitale
portato avanti dall'ultimo trade PRIMA della finestra;
2. il rendimento del primo trade in-finestra viene CANCELLATO dalla serie daily
(la testa e' piatta al valore post-trade -> pct_change = 0 anche il giorno del trade).
CORREZIONE (qui, solo per confronto): ffill PRIMA del reindex (carry-forward su tutta la
storia trade) + testa pre-primo-trade-assoluto = capitale iniziale 1000. MAI valori dal futuro.
Sleeve canonici interessati (serie a punti-trade -> testa di IDX scoperta):
DIP01_BTC, PR_ETHBTC, PR_ETHBTC_15M, PR_LTCETH, PR_ADAETH, PR_BTCLTC, PR_ETHSOL,
TSM01, XS01 (questi due quasi-densi: punti daily/12h -> impatto atteso ~0).
TR01_basket / ROT02_rot passano da _daily_equity ma con punti PER-BARRA (densi dal
2018) -> verificati comunque qui via monkeypatch runtime (nessun file canonico toccato).
I fade (combine_portfolio.py:52) e SH01 (shape_ml_validate.py:124) usano lo stesso
pattern reindex+bfill ma su equity PER-BARRA con dati che iniziano prima di IDX[0]
-> il bfill e' un no-op (verificato: nessun NaN in testa).
NB: le metriche OOS canoniche affettano la STESSA serie daily a SPLIT (metrics(dr,
lo=SPLIT)); la distorsione sta solo in testa (2021) -> l'OOS e' invariato per
costruzione se il primo trade in-finestra precede lo SPLIT. Questo script lo misura.
Uso: uv run python scripts/analysis/daily_equity_bfill_impact.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import scripts.analysis.honest_improve2 as hi2
from scripts.analysis.honest_improve2 import _norm, dip_market_gated
from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns
from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
from scripts.analysis.tsmom_research import tsmom_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS as PAIR_DEFS
from scripts.strategies.XS01_cross_sectional import xsec_sim
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
INIT = 1000.0
# ---------------- le due convenzioni ----------------
def daily_equity_buggy(ts_list, cap_list, idx):
"""Replica ESATTA di honest_improve2._daily_equity (per parity-check)."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
return s.resample("1D").last().reindex(idx).ffill().bfill()
def daily_equity_fixed(ts_list, cap_list, idx, init=INIT):
"""CORRETTA: ancora = capitale portato avanti dall'ultimo trade PRIMA della
finestra (ffill prima del reindex); pre-primo-trade assoluto = capitale iniziale."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
daily = s.resample("1D").last().ffill() # carry-forward su TUTTA la storia
daily = daily.reindex(idx).ffill() # coda oltre l'ultimo trade
return daily.fillna(init) # testa pre-primo-trade: capitale iniziale
def head_info(ts_list, cap_list, idx):
"""(primo giorno con trade dentro IDX, rendimento di testa perso dal bfill %)."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
raw = s.resample("1D").last().reindex(idx) # senza fill: NaN = nessun trade quel giorno
first = raw.first_valid_index()
if first is None:
return None, 0.0
fixed = daily_equity_fixed(ts_list, cap_list, idx)
lost = (fixed.loc[first] / fixed.iloc[0] - 1) * 100 # ritorno idx[0]->primo trade-day
return first.date(), float(lost)
def m2(eq: pd.Series):
dr = eq.pct_change().fillna(0.0)
return metrics(dr), metrics(dr, lo=SPLIT)
def fmt_pair(label, b, f):
d_sh = f["sharpe"] - b["sharpe"]
d_dd = f["dd"] - b["dd"]
d_rt = f["ret"] - b["ret"]
return (f" {label:<22s}"
f"Sh {b['sharpe']:6.2f}->{f['sharpe']:6.2f} ({d_sh:+.3f}) "
f"DD {b['dd']:6.2f}->{f['dd']:6.2f} ({d_dd:+.3f}pp) "
f"ret {b['ret']:+9.1f}->{f['ret']:+9.1f} ({d_rt:+8.2f}pp)")
def main():
print("=" * 110)
print(" IMPATTO bug bfill _daily_equity (honest_improve2.py:30) — attuale vs corretto")
print(f" IDX {IDX[0].date()} -> {IDX[-1].date()} | OOS da {OOS_DATE} (slice a SPLIT={SPLIT} sui rendimenti daily)")
print("=" * 110)
# ---------------- [1] baseline canonica (bfill cosi' com'e') ----------------
print("\n[1] build_everything() canonico (2-3 min)...")
from scripts.analysis.report_families import build_everything
S, pairs, tsm, shape = build_everything()
base = {**S, **pairs, **tsm, **shape}
# ---------------- [2] ri-simula gli sleeve a punti-trade ----------------
print("[2] ri-simulazione sleeve a punti-trade (parity-check + versione corretta)...")
raw: dict[str, tuple] = {}
d = dip_market_gated("BTC", market_n=0, return_equity=True)
raw["DIP01_BTC"] = (d["eq_ts"], d["eq_v"])
for a, b_, p in PAIR_DEFS:
r = pairs_sim(a, b_, **p)
raw[f"PR_{a}{b_}"] = (r["eq_ts"], r["eq_v"])
r15 = 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)
raw["PR_ETHBTC_15M"] = (r15["eq_ts"], r15["eq_v"])
t = tsmom_sim()
raw["TSM01"] = (t["eq_ts"], t["eq_v"])
x = xsec_sim()
raw["XS01"] = (x["eq_ts"], x["eq_v"])
fixed: dict[str, pd.Series] = {}
print(f"\n {'sleeve':<16s}{'parity(max|diff|)':>18s}{'1o trade in IDX':>17s}{'ret testa perso%':>18s}")
for k, (ts, v) in raw.items():
bug = _norm(daily_equity_buggy(ts, v, IDX))
par = float((bug - base[k]).abs().max())
fixed[k] = _norm(daily_equity_fixed(ts, v, IDX))
first, lost = head_info(ts, v, IDX)
flag = "" if par < 1e-9 else " <-- PARITY FAIL"
print(f" {k:<16s}{par:>18.2e}{str(first):>17s}{lost:>+18.3f}{flag}")
# TR01/ROT02: passano da _daily_equity ma con punti per-barra (densi) ->
# ricalcolo con monkeypatch RUNTIME della funzione (nessun file toccato).
orig_de = hi2._daily_equity
try:
hi2._daily_equity = daily_equity_fixed
tr_f = _norm(hi2._tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX))
rot_f = _norm(hi2._rot_daily_equity(IDX))
finally:
hi2._daily_equity = orig_de
for k, sf in (("TR01_basket", tr_f), ("ROT02_rot", rot_f)):
diff = float((sf - base[k]).abs().max())
print(f" {k:<16s}{'(denso)':>18s}{'':>17s}{diff:>18.2e} (diff fixed-vs-base: atteso ~0)")
fixed[k] = sf
# ---------------- [3] metriche per sleeve: attuale vs corretto ----------------
print("\n" + "=" * 110)
print(" (3) SLEEVE a punti-trade — FULL e OOS, attuale(bfill) -> corretto(carry-forward)")
print("=" * 110)
rows_oos_delta = {}
for k in fixed:
bf, bo = m2(base[k])
ff, fo = m2(fixed[k])
print(fmt_pair(f"{k} FULL", bf, ff))
print(fmt_pair(f"{k} OOS ", bo, fo))
rows_oos_delta[k] = (ff["sharpe"] - bf["sharpe"], ff["dd"] - bf["dd"],
fo["sharpe"] - bo["sharpe"], fo["dd"] - bo["dd"])
# ---------------- [4] PORT06: attuale vs corretto ----------------
print("\n" + "=" * 110)
print(" (4) PORT06 (cap PAIRS 0.33 + SHAPE 0.0588) — attuale vs corretto")
print("=" * 110)
p = PORTFOLIOS["PORT06"]
def port_m(members):
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
members_fix = {**base, **fixed}
bf, bo = port_m(base)
ff, fo = port_m(members_fix)
print(fmt_pair("PORT06 FULL", bf, ff))
print(fmt_pair("PORT06 OOS ", bo, fo))
# ---------------- [5] verdetto ----------------
print("\n" + "=" * 110)
print(" (5) VERDETTO (soglie materialita': >0.1 Sharpe o >0.5pp DD su PORT06 OOS)")
print("=" * 110)
d_sh_oos = abs(fo["sharpe"] - bo["sharpe"])
d_dd_oos = abs(fo["dd"] - bo["dd"])
d_sh_full = abs(ff["sharpe"] - bf["sharpe"])
d_dd_full = abs(ff["dd"] - bf["dd"])
materiale = d_sh_oos > 0.1 or d_dd_oos > 0.5
print(f" PORT06 OOS : dSharpe {fo['sharpe']-bo['sharpe']:+.4f} dDD {fo['dd']-bo['dd']:+.4f}pp"
f" -> {'MATERIALE' if materiale else 'NON materiale'}")
print(f" PORT06 FULL: dSharpe {ff['sharpe']-bf['sharpe']:+.4f} dDD {ff['dd']-bf['dd']:+.4f}pp")
worst = sorted(rows_oos_delta.items(), key=lambda kv: -abs(kv[1][0]) - abs(kv[1][1]) / 10)
print(" Sleeve piu' toccati (dSharpe FULL, dDD FULL, dSharpe OOS, dDD OOS):")
for k, (ds, dd_, dso, ddo) in worst[:5]:
print(f" {k:<16s} FULL {ds:+.3f} / {dd_:+.3f}pp OOS {dso:+.3f} / {ddo:+.3f}pp")
print("\n Nota strutturale: l'OOS canonico e' uno slice a SPLIT della stessa serie daily;")
print(" la distorsione bfill vive solo in testa (prima del primo trade in IDX) -> se il")
print(" primo trade in-finestra precede lo SPLIT, l'OOS e' INVARIATO per costruzione.")
if __name__ == "__main__":
main()
@@ -0,0 +1,146 @@
"""PIANO STORICO DERIBIT — quanta storia copre davvero il venue dove ESEGUIAMO?
Obiettivo: scegliere la fonte migliore per ricostruire lo storico di backtest, dato
che si esegue su Deribit. Principio (gia' misurato in multi_source_check): l'ancora
giusta e' il VENUE DI ESECUZIONE, non Binance/USDT. Qui rispondo con i numeri a:
1. COPERTURA: da quando esiste OHLCV su Deribit MAINNET (ccxt pubblico, no token) per
gli strumenti che tradiamo — inverse (BTC/ETH-PERPETUAL) e lineari USDC.
2. TIMEFRAME nativi disponibili su Deribit.
3. FEDELTA' inverse-vs-lineare (stesso indice? -> posso usare l'inverse, storia lunga,
come price-series e i lineari recenti sono ridondanti per il PREZZO).
4. GAP pre-Deribit: quanto indietro vanno le strategie e cosa manca -> da gap-fillare
con Coinbase USD (spot, NON USDT).
Tutto via ccxt pubblico Deribit (= api.deribit.com mainnet, reale). Non modifica nulla.
uv run python scripts/analysis/deribit_history_plan.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
import ccxt
DERIBIT = ccxt.deribit({"enableRateLimit": True})
COINBASE = ccxt.coinbase({"enableRateLimit": True})
def earliest(symbol: str, tf: str = "1d") -> tuple[str | None, int, str | None]:
"""Trova la prima candela disponibile (probe since 2016) + n candele totali stimate."""
since = int(pd.Timestamp("2016-01-01", tz="UTC").timestamp() * 1000)
try:
rows = DERIBIT.fetch_ohlcv(symbol, tf, since=since, limit=5000)
except Exception as e:
return None, 0, f"{type(e).__name__}: {str(e)[:60]}"
if not rows:
return None, 0, "no-data"
first = pd.to_datetime(int(rows[0][0]), unit="ms", utc=True)
last = pd.to_datetime(int(rows[-1][0]), unit="ms", utc=True)
return f"{first.date()} -> {last.date()}", len(rows), None
def list_perps() -> dict:
"""Risolve i simboli ccxt reali dei perp Deribit per BTC/ETH (inverse + lineari)."""
DERIBIT.load_markets()
found = {}
for sym, m in DERIBIT.markets.items():
if not m.get("swap"):
continue
base = m.get("base")
if base not in ("BTC", "ETH"):
continue
settle = m.get("settle")
kind = "inverse" if m.get("inverse") else "linear"
found[f"{base}-{kind}({settle})"] = sym
return found
def fetch_series(ex, symbol, tf, start, end, limit=1000):
start_ms = int(pd.Timestamp(start, tz="UTC").timestamp() * 1000)
end_ms = int(pd.Timestamp(end, tz="UTC").timestamp() * 1000)
tf_ms = ex.parse_timeframe(tf) * 1000
out, since = {}, start_ms
while since <= end_ms:
try:
rows = ex.fetch_ohlcv(symbol, tf, since=since, limit=limit)
except Exception:
break
if not rows:
break
for r in rows:
if start_ms <= int(r[0]) <= end_ms and r[4]:
out[int(r[0])] = float(r[4])
nxt = int(rows[-1][0]) + tf_ms
if nxt <= since:
break
since = nxt
if len(rows) < limit and since > end_ms:
break
return pd.Series(out)
def dev_bps(a: pd.Series, b: pd.Series) -> tuple[int, float, float, float]:
df = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner")
if len(df) == 0:
return 0, 0, 0, 0
d = (df["a"] - df["b"]).abs() / df["b"] * 1e4
return len(df), float(d.median()), float(d.quantile(.95)), float(d.max())
def main():
print("=" * 84)
print(" PIANO STORICO DERIBIT MAINNET (ccxt pubblico, reale)")
print("=" * 84)
print("\n[1] Simboli perp Deribit BTC/ETH risolti:")
perps = list_perps()
for k, v in perps.items():
print(f" {k:<22s} -> {v}")
print("\n[2] COPERTURA storica (1d, probe da 2016):")
print(f" {'strumento':<22s}{'range disponibile':<28s}{'giorni':>8s}")
cov = {}
for k, sym in perps.items():
rng, n, err = earliest(sym, "1d")
cov[k] = (sym, rng, n)
print(f" {k:<22s}{(rng or err or '-'):<28s}{n:>8d}")
print("\n[3] TIMEFRAME nativi Deribit (test su BTC inverse, oggi):")
bsym = next((s for k, s in perps.items() if k.startswith("BTC-inverse")), "BTC/USD:BTC")
tfs = []
for tf in ("1m", "3m", "5m", "10m", "15m", "30m", "1h", "2h", "3h", "4h", "6h", "12h", "1d"):
try:
r = DERIBIT.fetch_ohlcv(bsym, tf, limit=3)
tfs.append(tf if r else f"{tf}:vuoto")
except Exception:
tfs.append(f"{tf}:NO")
print(f" ok: {[t for t in tfs if ':' not in t]}")
print(f" ko: {[t for t in tfs if ':' in t]}")
print("\n[4] FEDELTA' inverse-vs-lineare USDC (close 1h, ultimi ~40g):")
for base in ("BTC", "ETH"):
inv = next((s for k, s in perps.items() if k.startswith(f"{base}-inverse")), None)
lin = next((s for k, s in perps.items() if k.startswith(f"{base}-linear")), None)
if not inv or not lin:
print(f" {base}: manca inverse o lineare"); continue
a = fetch_series(DERIBIT, inv, "1h", "2026-04-15", "2026-05-27")
b = fetch_series(DERIBIT, lin, "1h", "2026-04-15", "2026-05-27")
n, med, p95, mx = dev_bps(a, b)
print(f" {base}: barre={n} inverse-vs-lineare med {med:.1f} bps p95 {p95:.1f} max {mx:.1f}")
print("\n[5] GAP pre-Deribit: Deribit inverse vs Coinbase USD su finestra PROFONDA (2020-06, 1d):")
for base in ("BTC", "ETH"):
inv = next((s for k, s in perps.items() if k.startswith(f"{base}-inverse")), None)
a = fetch_series(DERIBIT, inv, "1d", "2020-06-01", "2020-09-01")
b = fetch_series(COINBASE, f"{base}/USD", "1d", "2020-06-01", "2020-09-01", limit=300)
n, med, p95, mx = dev_bps(a, b)
cov_first = cov.get(f"{base}-inverse(BTC)" if base == "BTC" else f"{base}-inverse(ETH)", (None, "?", 0))[1]
print(f" {base}: Deribit-vs-Coinbase barre={n} med {med:.1f} bps p95 {p95:.1f} max {mx:.1f}")
if __name__ == "__main__":
main()
+227
View File
@@ -0,0 +1,227 @@
"""GATE DIP01 + PORT06: estendere EXIT-16 (close-confirm SL) a DIP01 (sweep punto 9).
DIP01 e' l'unico sleeve BTC con esecuzione REALE round-trip, e gira ancora col
branch SL intrabar wick-sensitive. EXIT-16 e' stato validato SULLE FADE: estenderlo
a una strategia honest richiede la validazione sul grid proprio di DIP01, con
engine GAP-AWARE (lezione exit-lab: l'engine canonico filla gli stop "al livello"
anche su gap-through -> bias PRO stop intrabar stretti; il confronto onesto filla
lo SL a worse(livello, open)).
Protocollo:
[1] parita': replay engine 'orig' (fill al livello) == equity canonica DIP01_BTC
[2] grid 3x3x2 (z_in x sl_atr x max_bars) su BTC (deployato) ed ETH (robustezza):
orig GAP-AWARE vs EXIT-16(buf 0.5), ret/DD/Sharpe train (pre-OOS) e OOS
[3] plateau buffer {0.4, 0.5, 0.75, 1.0} sulla cella canonica
[4] gate PORT06: DIP01_BTC exit16 innestato nel canonico, pesi cap
-> PROMOSSO se OOS Sharpe non peggiora E FULL/DD non degradano materialmente.
NB hurst_max NON valutato: il gate trendmax (2026-06-07) ha mostrato che il
loss-guard Hurst e' ridondante-dannoso POST-EXIT-16 (stesso regime target).
uv run python scripts/analysis/dip01_exit16_impact.py
"""
from __future__ import annotations
import sys
from pathlib import Path
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.analysis.strategy_research import atr
from scripts.analysis.combine_portfolio import _norm, IDX, metrics, SPLIT, OOS_DATE
from scripts.analysis._port06_gate_common import port_metrics
from scripts.portfolios._defs import PORTFOLIOS
FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0
BUFFER = 0.5
GRID_Z = (2.0, 2.5, 3.0)
GRID_SL = (2.0, 2.5, 3.0)
GRID_MB = (24, 48)
CANON = dict(n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
def dip_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
"""Entries DIP01 == honest_improve2.dip_market_gated (market_n=0): crossing
di z sotto -z_in. Ritorna [{i, tp, sl, mb}] (long-only)."""
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
out = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
out.append({"i": i, "tp": ma[i], "sl": c[i] - sl_atr * a[i],
"mb": max_bars})
return out
def dip_trades(ents, df, mode, buffer=BUFFER):
"""Engine exit DIP01 (long-only), non-overlap come il canonico.
mode="orig" : SL intrabar fill AL LIVELLO (== canonico, per la parita')
mode="orig_gap" : SL intrabar fill a worse(livello, open[j]) — gap-aware
mode="exit16" : SL intrabar OFF; TP intrabar al livello (priorita' nel bar);
stop solo se close[j] < sl - buffer*ATR14[j], fill a close[j]
"""
h, l, c, o = df["high"].values, df["low"].values, df["close"].values, df["open"].values
n = len(c)
a = atr(df, 14)
fee = FEE_RT * LEV
out = []
last = -1
for e in ents:
i = e["i"]
if i <= last or i + 1 >= n:
continue
tp, sl, mb = e["tp"], e["sl"], e["mb"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
j = n - 1
exit_p = c[j]
break
if mode in ("orig", "orig_gap"):
if l[j] <= sl:
exit_p = sl if mode == "orig" else min(sl, o[j])
break
if h[j] >= tp:
exit_p = tp
break
if k == mb:
exit_p = c[j]
else: # exit16
if h[j] >= tp:
exit_p = tp
break
aj = a[j] if np.isfinite(a[j]) else 0.0
if c[j] < sl - buffer * aj:
exit_p = c[j]
break
if k == mb:
exit_p = c[j]
ret = (exit_p - c[i]) / c[i] * LEV - fee
out.append((i, j, ret))
last = j
return out
def daily_equity(df, trades):
"""Equity giornaliera con la convenzione CANONICA honest (_daily_equity su punti
trade-exit). NB: la serie a punti-trade reindexata su IDX ancora il primo valore
al PRIMO trade dentro IDX (bfill), non al capitale portato avanti da prima —
convenzione discutibile ma e' quella di build_everything: per la parita' (e il
confronto col PORT06 canonico) va replicata esattamente."""
from scripts.analysis.honest_improve2 import _daily_equity
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
cap = INIT
eq_ts, eq_v = [], []
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * POS * ret, 10.0)
eq_ts.append(ts.iloc[j])
eq_v.append(cap)
return _norm(_daily_equity(eq_ts, eq_v, IDX))
def cell_metrics(eq):
dr = eq.pct_change().fillna(0.0)
return metrics(dr), metrics(dr, lo=SPLIT)
def main():
p = PORTFOLIOS["PORT06"]
print("=" * 104)
print(" GATE DIP01 EXIT-16 (close-confirm 0.5 ATR) — grid gap-aware + PORT06")
print(f" OOS da {OOS_DATE} | fee {FEE_RT*100:.2f}%RT x lev{LEV:.0f} | pos {POS}")
print("=" * 104)
print("\n[1] build_everything() canonico (cache)...")
from src.portfolio.sleeves import all_sleeve_equities
eq_base = dict(all_sleeve_equities())
dfs = {a: load_data(a, "1h") for a in ("BTC", "ETH")}
# --- parita' ---
ents = dip_entries(dfs["BTC"], **CANON)
rep = daily_equity(dfs["BTC"], dip_trades(ents, dfs["BTC"], "orig"))
base = eq_base["DIP01_BTC"]
corr = base.pct_change().fillna(0).corr(rep.pct_change().fillna(0))
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
rr = (rep.iloc[-1] / rep.iloc[0] - 1) * 100
print(f"\n[1] PARITA' orig vs canonico: corr={corr:.5f} ret {rb:+.0f}% vs {rr:+.0f}%")
if not (corr > 0.999 and abs(rr - rb) <= max(1.0, abs(rb) * 0.01)):
print(" >>> PARITA' FALLITA: STOP.")
return
# --- [2] grid gap-aware ---
for asset in ("BTC", "ETH"):
df = dfs[asset]
print(f"\n[2] GRID {asset} — orig GAP-AWARE vs EXIT-16 (train | OOS: ret% e Sharpe)")
print(f" {'cella':<16s}{'tr retO':>9s}{'tr retE':>9s} {'oos retO':>9s}{'oos retE':>9s}"
f" {'oos ShO':>8s}{'oos ShE':>8s} {'ddO':>6s}{'ddE':>6s} esito")
wins_tr = wins_oos = cells = 0
for z in GRID_Z:
for slm in GRID_SL:
for mb in GRID_MB:
ents = dip_entries(df, n=50, z_in=z, sl_atr=slm, max_bars=mb)
eo = daily_equity(df, dip_trades(ents, df, "orig_gap"))
ee = daily_equity(df, dip_trades(ents, df, "exit16"))
fo, oo = cell_metrics(eo)
fe, oe = cell_metrics(ee)
tr_o = fo["ret"] - oo["ret"]; tr_e = fe["ret"] - oe["ret"] # ~train (full-oos, approssimato su ret composti: usare segni)
# train ret esatto: equity al SPLIT
tr_o = (eo.iloc[SPLIT] / eo.iloc[0] - 1) * 100
tr_e = (ee.iloc[SPLIT] / ee.iloc[0] - 1) * 100
cells += 1
w_tr = tr_e >= tr_o
w_oos = oe["ret"] >= oo["ret"]
wins_tr += w_tr
wins_oos += w_oos
tag = ("OK" if (w_tr and w_oos) else "tr-" if w_oos else "oos-" if w_tr else "KO")
print(f" z{z} sl{slm} mb{mb:<3d}{tr_o:>9.0f}{tr_e:>9.0f} "
f"{oo['ret']:>9.0f}{oe['ret']:>9.0f} {oo['sharpe']:>8.2f}{oe['sharpe']:>8.2f}"
f" {fo['dd']:>6.1f}{fe['dd']:>6.1f} {tag}")
print(f" -> EXIT-16 >= orig-gap: train {wins_tr}/{cells}, OOS {wins_oos}/{cells}")
# --- [3] plateau buffer (BTC, cella canonica) ---
print("\n[3] Plateau buffer EXIT-16 (BTC, cella canonica):")
ents = dip_entries(dfs["BTC"], **CANON)
for buf in (0.4, 0.5, 0.75, 1.0):
ee = daily_equity(dfs["BTC"], dip_trades(ents, dfs["BTC"], "exit16", buffer=buf))
fe, oe = cell_metrics(ee)
print(f" buf {buf:<5}FULL ret {fe['ret']:>+7.0f}% DD {fe['dd']:>5.1f} Sh {fe['sharpe']:>5.2f}"
f" | OOS ret {oe['ret']:>+6.0f}% DD {oe['dd']:>5.1f} Sh {oe['sharpe']:>5.2f}")
# --- [4] gate PORT06 ---
ee = daily_equity(dfs["BTC"], dip_trades(ents, dfs["BTC"], "exit16"))
members_b = dict(eq_base)
members_e = dict(eq_base)
members_e["DIP01_BTC"] = ee
f_b, o_b = port_metrics(members_b, p)
f_e, o_e = port_metrics(members_e, p)
print("\n" + "=" * 104)
print(f" [4] PORT06 (pesi cap {p.caps}) — DIP01_BTC orig vs EXIT-16")
print("=" * 104)
print(f" {'variante':<10s}{'FULL Sh':>9s}{'FULL DD%':>10s}{'CAGR':>6s} | {'OOS Sh':>7s}{'OOS DD%':>8s}{'CAGR':>6s}")
for nm, (f, o) in (("BASE", (f_b, o_b)), ("EXIT-16", (f_e, o_e))):
print(f" {nm:<10s}{f['sharpe']:>9.2f}{f['dd']:>10.2f}{f['cagr']:>5.0f}% | "
f"{o['sharpe']:>7.2f}{o['dd']:>8.2f}{o['cagr']:>5.0f}%")
oos_ok = o_e["sharpe"] >= o_b["sharpe"] - 0.02 and o_e["dd"] <= o_b["dd"] + 0.20
full_ok = f_e["sharpe"] >= f_b["sharpe"] - 0.02 and f_e["dd"] <= f_b["dd"] + 0.20
promoted = oos_ok and full_ok
print(f"\n GATE: OOS {'OK' if oos_ok else 'KO'} | FULL {'OK' if full_ok else 'KO'}")
print(" VERDETTO: " + (">>> PROMOSSO <<<" if promoted else ">>> BOCCIATO <<<"))
if __name__ == "__main__":
main()
@@ -0,0 +1,156 @@
"""GATE PORT06 del candidato index_comp_disp W=168 (ricerca dispersion 2026-06-08).
Edge confermato avversarialmente: fade della componente idiosincratica di BTC verso
l'indice EW, gated da alta dispersione. Config: rel_len=12, z_win=336, z_thr=1.5,
disp_168 >= quantile rolling 0.7 (win 720), TP=1.0*ATR14, SL=1.5*ATR14, max_bars=24.
Domanda del gate (lezione FR01: robusto != migliora-il-portafoglio):
1) correlazione daily col MASTER e con le fade BTC esistenti (e' un diversificatore?)
2) PORT06 BASE (17 sleeve) vs +DISP (18 sleeve) con pesi cap: DeltaSharpe/DeltaDD FULL e OOS.
PROMOSSO solo se decorrela E migliora (o non degrada) l'OOS.
uv run python scripts/analysis/dispersion_edges/gate_index_comp_disp.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.dispersion_lab import features, align_to
from scripts.analysis.explore_lab import get_df, atr
from scripts.analysis.combine_portfolio import _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE
from scripts.analysis.honest_improve2 import _daily_equity
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
FEE_RT, LEV, POS, INIT = 0.001, 3.0, 0.15, 1000.0
CFG = dict(rel_len=12, z_win=336, z_thr=1.5, disp_q=0.7, disp_q_win=720,
tp_atr=1.0, sl_atr=1.5, max_bars=24)
def _last_rank(x):
if x.shape[0] < 2:
return np.nan
return float((x[:-1] < x[-1]).mean())
def build_trades(asset="BTC"):
"""Entries CAUSALI + exit intrabar (TP/SL/max_bars) -> [(i, j, ret_netto)]."""
df = get_df(asset, "1h")
F = features()
fa = align_to(F, df)
c, h, l = df["close"].values, df["high"].values, df["low"].values
n = len(c)
a14 = atr(df, 14)
rel = fa[f"rel_{asset}"].values.astype(float)
disp = fa["disp_168"].values.astype(float)
# somma rolling rel su rel_len, z-score causale (mean/std rolling z_win shift 1)
rs = pd.Series(rel).rolling(CFG["rel_len"]).sum()
rmean = rs.rolling(CFG["z_win"]).mean().shift(1)
rstd = rs.rolling(CFG["z_win"]).std().shift(1)
z = ((rs - rmean) / rstd.replace(0, np.nan)).values
dpct = pd.Series(disp).rolling(CFG["disp_q_win"]).apply(_last_rank, raw=True).values
fee = FEE_RT * LEV
out = []
last = -1
for i in range(n - 1):
if i <= last or not np.isfinite(z[i]) or not np.isfinite(dpct[i]):
continue
if dpct[i] < CFG["disp_q"] or abs(z[i]) < CFG["z_thr"]:
continue
ai = a14[i]
if not np.isfinite(ai) or ai <= 0:
continue
d = -1 if z[i] > 0 else 1
tp = c[i] + d * CFG["tp_atr"] * ai
sl = c[i] - d * CFG["sl_atr"] * ai
mb = CFG["max_bars"]
j = min(i + mb, n - 1)
exit_p = c[j]
for k in range(1, mb + 1):
j = i + k
if j >= n:
j = n - 1; exit_p = c[j]; break
if d == 1:
if l[j] <= sl: exit_p = sl; break
if h[j] >= tp: exit_p = tp; break
else:
if h[j] >= sl: exit_p = sl; break
if l[j] <= tp: exit_p = tp; break
if k == mb: exit_p = c[j]
out.append((i, j, (exit_p - c[i]) / c[i] * d * LEV - fee))
last = j
return df, out
def daily_equity(df, trades):
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
cap = INIT; eq_ts, eq_v = [], []
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * POS * ret, 10.0)
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
return _norm(_daily_equity(eq_ts, eq_v, IDX))
def pmetrics(members, p, extra=None):
ids = list(p.sleeve_ids) + ([extra] if extra else [])
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
if extra:
caps = dict(p.caps); caps["DISP"] = caps.get("DISP", None)
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters={**{i:(p.clusters or {}).get(i,i) for i in p.sleeve_ids},
**({extra:"disp"} if extra else {})},
lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def main():
p = PORTFOLIOS["PORT06"]
print("=" * 100)
print(" GATE PORT06 — candidato index_comp_disp W=168 (BTC) | famiglia DISP nuova")
print(f" config {CFG} | OOS da {OOS_DATE}")
print("=" * 100)
from src.portfolio.sleeves import all_sleeve_equities
eq_base = dict(all_sleeve_equities())
df, trades = build_trades("BTC")
disp_eq = daily_equity(df, trades)
fr = (disp_eq.iloc[-1] / disp_eq.iloc[0] - 1) * 100
o = disp_eq.iloc[SPLIT:]; ofr = (o.iloc[-1] / o.iloc[0] - 1) * 100
print(f"\n[1] candidato standalone: {len(trades)} trade | FULL {fr:+.0f}% | OOS {ofr:+.0f}%")
# correlazione daily col MASTER e con le fade BTC
dr_cand = disp_eq.pct_change().fillna(0.0)
print("\n[2] correlazione daily col candidato (decorrela?):")
for sid in ["MR01_BTC", "MR02_BTC", "MR07_BTC", "DIP01_BTC"]:
corr = dr_cand.corr(eq_base[sid].pct_change().fillna(0.0))
print(f" {sid:<12} corr {corr:+.3f}")
master_dr = pd.DataFrame({i: eq_base[i].pct_change().fillna(0.0) for i in p.sleeve_ids}).mean(axis=1)
print(f" {'MASTER(EW)':<12} corr {dr_cand.corr(master_dr):+.3f}")
# PORT06 base vs +DISP
f_b, o_b = pmetrics(eq_base, p)
members = dict(eq_base); members["DISP_BTC"] = disp_eq
f_e, o_e = pmetrics(members, p, extra="DISP_BTC")
print("\n[3] PORT06 BASE (17) vs +DISP (18):")
print(f" {'':<10}{'FULL Sh':>9}{'FULL DD%':>10}{'OOS Sh':>9}{'OOS DD%':>9}")
print(f" {'BASE':<10}{f_b['sharpe']:>9.2f}{f_b['dd']:>10.2f}{o_b['sharpe']:>9.2f}{o_b['dd']:>9.2f}")
print(f" {'+DISP':<10}{f_e['sharpe']:>9.2f}{f_e['dd']:>10.2f}{o_e['sharpe']:>9.2f}{o_e['dd']:>9.2f}")
print(f" {'DELTA':<10}{f_e['sharpe']-f_b['sharpe']:>+9.2f}{f_e['dd']-f_b['dd']:>+10.2f}"
f"{o_e['sharpe']-o_b['sharpe']:>+9.2f}{o_e['dd']-o_b['dd']:>+9.2f}")
promoted = (o_e['sharpe'] >= o_b['sharpe'] - 0.02 and o_e['dd'] <= o_b['dd'] + 0.20
and f_e['sharpe'] >= f_b['sharpe'] - 0.02)
print("\n VERDETTO: " + (">>> PROMOSSO <<<" if promoted else ">>> BOCCIATO (diluisce, come FR01) <<<"))
if __name__ == "__main__":
main()
@@ -0,0 +1,191 @@
"""FAMIGLIA index_comp_disp (W=24) — dispersion trading REALIZZATO.
Idea: l'indice EW vs le componenti. Quando la dispersione cross-sectional rolling
(disp_24) e' ALTA, le componenti idiosincratiche estreme (rel_A = ret_A - ret_idx)
tendono a RIENTRARE verso l'indice -> fade della componente idiosincratica estrema:
- se rel_A e' molto positivo (A ha sovraperformato l'indice oltre soglia) -> SHORT A
- se rel_A e' molto negativo (A ha sottoperformato l'indice oltre soglia) -> LONG A
condizionato a disp_24 sopra una soglia (regime di alta dispersione).
CAUSALE: la decisione a close[i] usa SOLO feature note a i:
- rel_A[i] = log-ret di A meno log-ret indice nella barra [i-1->i] (nota a close[i])
- disp_24[i] = media rolling 24 della disp cross-sectional fino a i (nota a close[i])
Ingresso eseguibile a close[i]. Niente uso di i+1 nella decisione.
Per rendere le soglie comparabili fra asset/tempo, rel_A si normalizza con la sua
deviazione standard rolling CAUSALE (rolling 168h, shiftata di 1 per non includere i).
disp_24 si normalizza col suo quantile rolling causale (percentile rolling).
Exit: time-stop max_bars (con TP/SL ATR opzionali). Il fade verso l'indice e' un
ritorno alla media -> orizzonte breve.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.dispersion_lab import features, align_to, UNIVERSE # noqa: E402
from scripts.analysis.explore_lab import get_df, evaluate, robust, atr # noqa: E402
W = 24 # finestra famiglia
def _last_rank(x: np.ndarray) -> float:
"""Frazione dei valori (esclusi l'ultimo) strettamente < dell'ultimo. Causale:
l'ultimo elemento e' la barra i, confrontata coi 719 valori precedenti."""
if x.shape[0] < 2:
return np.nan
return float((x[:-1] < x[-1]).mean())
def _causal_signals(asset: str, df: pd.DataFrame, fa: pd.DataFrame):
"""Precalcola (una volta per asset) rel_z, disp_pctl, atr — feature CAUSALI."""
a14 = atr(df, 14)
rel = fa[f"rel_{asset}"].values.astype(float)
disp = fa["disp_24"].values.astype(float)
# z-score CAUSALE di rel: media/std rolling 168h SHIFTATA di 1 (solo barre <= i-1)
rel_s = pd.Series(rel)
rmean = rel_s.rolling(168).mean().shift(1)
rstd = rel_s.rolling(168).std().shift(1)
rel_z = ((rel_s - rmean) / rstd.replace(0, np.nan)).values
# percentile rolling CAUSALE di disp_24 (rank di disp[i] vs i 720 valori fino a i).
# vettoriale via rank rolling: pos dell'ultimo elemento / (win-1).
win = 720
dr = pd.Series(disp).rolling(win).apply(_last_rank, raw=True)
disp_pctl = dr.values
return rel_z, disp_pctl, a14
def build_entries(asset: str, df: pd.DataFrame, fa: pd.DataFrame,
rel_z_thr: float, disp_pctl_thr: float,
max_bars: int, tp_atr: float | None, sl_atr: float | None,
precomp=None) -> list[dict]:
"""Costruisce entries CAUSALI per il fade della componente idiosincratica."""
n = len(df)
c = df["close"].values
if precomp is None:
rel_z, disp_pctl, a14 = _causal_signals(asset, df, fa)
else:
rel_z, disp_pctl, a14 = precomp
entries: list[dict] = []
for i in range(n - 1):
z = rel_z[i]
dp = disp_pctl[i]
if not np.isfinite(z) or not np.isfinite(dp):
continue
if dp < disp_pctl_thr: # solo regime di alta dispersione
continue
if abs(z) < rel_z_thr: # solo componente idio estrema
continue
d = -1 if z > 0 else +1 # FADE: rel alto -> short A; rel basso -> long A
a = a14[i]
if not np.isfinite(a) or a <= 0:
tp = sl = None
else:
tp = c[i] + d * tp_atr * a if tp_atr else None
sl = c[i] - d * sl_atr * a if sl_atr else None
entries.append({"i": i, "d": d, "max_bars": max_bars, "tp": tp, "sl": sl})
return entries
def check_no_lookahead(asset: str, df: pd.DataFrame, fa: pd.DataFrame) -> bool:
"""Perturba i PREZZI dopo un indice T e verifica che le entries con i<=T non cambino.
Qui ricostruiamo la entry-rule su una copia di df/fa col futuro alterato e confrontiamo
le entries (i, d) con i<=T."""
n = len(df)
T = int(n * 0.6)
base = build_entries(asset, df, fa, rel_z_thr=2.0, disp_pctl_thr=0.6,
max_bars=12, tp_atr=None, sl_atr=None)
# perturba i prezzi dopo T: alza del 50% close/high/low/open
df2 = df.copy()
for col in ("open", "high", "low", "close"):
df2.loc[df2.index > T, col] = df2.loc[df2.index > T, col] * 1.5
# perturba anche le feature riferite a barre > T (rel_<asset>, disp_24)
fa2 = fa.copy()
for col in (f"rel_{asset}", "disp_24"):
fa2.loc[fa2.index > T, col] = fa2.loc[fa2.index > T, col] * 1.5
pert = build_entries(asset, df2, fa2, rel_z_thr=2.0, disp_pctl_thr=0.6,
max_bars=12, tp_atr=None, sl_atr=None)
base_le = {(e["i"], e["d"]) for e in base if e["i"] <= T}
pert_le = {(e["i"], e["d"]) for e in pert if e["i"] <= T}
ok = base_le == pert_le
print(f"[no-look-ahead] entries con i<=T={T} invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'} (base={len(base_le)} pert={len(pert_le)})")
if not ok:
diff = (base_le ^ pert_le)
print(f" differenze: {sorted(diff)[:10]}")
return ok
def main():
F = features()
print(f"feature caricate: {F.shape[0]} barre")
# asset single-asset da esplorare (i piu' liquidi + qualche alt)
assets = ["BTC", "ETH", "SOL", "ADA", "BNB", "DOGE", "LTC", "XRP"]
# griglia piccola di soglie
rel_z_grid = [1.5, 2.0, 2.5]
disp_pctl_grid = [0.5, 0.7]
mb_grid = [6, 12, 24]
# exit: prima senza tp/sl (puro time-stop), poi con un TP/SL ATR moderato
exit_grid = [
(None, None),
(1.5, 2.0),
]
best = None
# no-look-ahead check una volta (su ETH)
df_eth = get_df("ETH", "1h")
fa_eth = align_to(F, df_eth)
la_ok = check_no_lookahead("ETH", df_eth, fa_eth)
print()
for asset in assets:
df = get_df(asset, "1h")
fa = align_to(F, df)
precomp = _causal_signals(asset, df, fa) # una volta per asset (costoso)
for rz in rel_z_grid:
for dp in disp_pctl_grid:
for mb in mb_grid:
for (tp_a, sl_a) in exit_grid:
ents = build_entries(asset, df, fa, rz, dp, mb, tp_a, sl_a,
precomp=precomp)
if len(ents) < 30:
continue
tag = f"{asset} rz{rz} dp{dp} mb{mb} tp{tp_a} sl{sl_a}"
res = evaluate(tag, ents, df)
rb = robust(res)
# criterio "migliore": OOS ret, poi sharpe; preferisci robuste
score = (rb, res["oos"]["ret"], res["full"]["sharpe"])
if best is None or score > best[0]:
best = (score, tag, res, rb,
dict(asset=asset, rz=rz, dp=dp, mb=mb, tp=tp_a, sl=sl_a))
print("\n=== MIGLIORE ===")
if best is None:
print("nessuna cella con abbastanza trade")
return
score, tag, res, rb, cfg = best
print(f"config: {tag}")
print(f"robust={rb} lookahead_ok={la_ok}")
print(f"FULL ret={res['full']['ret']:+.0f}% OOS ret={res['oos']['ret']:+.0f}% "
f"DD={res['full']['dd']:.0f}% Sharpe={res['full']['sharpe']:.2f}")
print(f"fee0.2% OOS={res['sweep_oos'][0.002]:+.0f}% anniPos={res['pos_yrs']}/{res['n_yrs']}")
if __name__ == "__main__":
main()
@@ -0,0 +1,152 @@
"""rel_idio_fade (W=24): fade della componente idiosincratica rel_A vs indice.
Idea: rel_A = ret(A) - ret(indice EW) e' il rendimento idiosincratico (residuo di
mercato). Quando l'asset diverge troppo dall'indice (z-score di rel_A su finestra
W=24 elevato), si fada il residuo verso l'indice: se A ha sovraperformato troppo
(z alto) -> SHORT A; se ha sottoperformato (z basso) -> LONG A. Mean-reversion del
residuo.
ENTRY CAUSALE: la decisione a close[i] usa SOLO rel_A fino a i incluso. Lo z-score
e' costruito con media/std rolling su [i-W+1 .. i] (causale). Ingresso eseguibile a
close[i]; exit a tempo (max_bars), opzionale TP/SL ad ATR.
Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/_disp_scratch/rel_idio_fade_24.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.dispersion_lab import features, align_to, UNIVERSE # noqa: E402
from scripts.analysis.explore_lab import get_df, evaluate, robust, atr # noqa: E402
W = 24 # finestra correlazione/dispersione (richiesta dalla famiglia)
ASSETS = ["BTC", "ETH", "ADA", "BNB", "DOGE", "LTC", "SOL", "XRP"]
Z_GRID = [1.5, 2.0, 2.5, 3.0]
MB_GRID = [12, 24, 48]
TP_ATR = None # exit a tempo puro per il primo screening
SL_ATR = None
def build_entries(asset: str, df: pd.DataFrame, fa: pd.DataFrame,
z_thr: float, max_bars: int,
tp_atr=None, sl_atr=None) -> list[dict]:
"""Entries CAUSALI per il fade del residuo idiosincratico.
z[i] = (rel[i] - mean(rel[i-W+1..i])) / std(rel[i-W+1..i]) -> usa solo dati <= i.
rel[i] e' gia' causale (deriva da log-ret fino a close[i]). Quando |z[i]|>=thr:
z>0 (A ha sovraperformato l'indice) -> SHORT (d=-1), fade verso l'indice
z<0 (A ha sottoperformato) -> LONG (d=+1)
"""
rel = fa[f"rel_{asset}"].values.astype(float)
s = pd.Series(rel)
mu = s.rolling(W).mean().values
sd = s.rolling(W).std(ddof=0).values
z = (rel - mu) / np.where(sd > 0, sd, np.nan)
a = atr(df, 14)
c = df["close"].values
n = len(c)
entries: list[dict] = []
for i in range(W, n - 1):
zi = z[i]
if not np.isfinite(zi) or abs(zi) < z_thr:
continue
d = -1 if zi > 0 else 1 # fade del residuo
e = {"i": i, "d": d, "max_bars": max_bars}
if tp_atr is not None and np.isfinite(a[i]):
e["tp"] = c[i] + d * tp_atr * a[i] # TP nella direzione del fade
if sl_atr is not None and np.isfinite(a[i]):
e["sl"] = c[i] - d * sl_atr * a[i]
entries.append(e)
return entries
def check_no_lookahead(asset: str, df: pd.DataFrame, fa: pd.DataFrame,
z_thr: float, max_bars: int) -> bool:
"""Perturba i prezzi DOPO un indice T e verifica che le entries con i<=T non
cambino (la entry-rule usa solo dati <= close[i])."""
rel = fa[f"rel_{asset}"].values.astype(float)
n = len(rel)
T = int(n * 0.6)
def z_of(relv):
s = pd.Series(relv)
mu = s.rolling(W).mean().values
sd = s.rolling(W).std(ddof=0).values
return (relv - mu) / np.where(sd > 0, sd, np.nan)
z0 = z_of(rel)
rel2 = rel.copy()
rel2[T + 1:] = rel2[T + 1:] + 0.05 # shock del futuro
z2 = z_of(rel2)
def ents_from(z):
out = []
for i in range(W, n - 1):
if i > T:
break
zi = z[i]
if np.isfinite(zi) and abs(zi) >= z_thr:
out.append((i, -1 if zi > 0 else 1))
return out
ok = ents_from(z0) == ents_from(z2)
print(f" [no-look-ahead {asset}] entries i<=T={T} invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'}")
return ok
def main():
F = features()
print(f"feature caricate: {F.shape[0]} barre, {F.shape[1]} colonne")
best = None
look_ok_all = True
for asset in ASSETS:
df = get_df(asset, "1h")
fa = align_to(F, df)
# un check no-look-ahead per asset (config centrale)
look_ok_all &= check_no_lookahead(asset, df, fa, z_thr=2.0, max_bars=24)
print(f"--- {asset} ---")
for z_thr in Z_GRID:
for mb in MB_GRID:
ents = build_entries(asset, df, fa, z_thr, mb, TP_ATR, SL_ATR)
if len(ents) < 30:
continue
name = f"{asset} z{z_thr} mb{mb}"
res = evaluate(name, ents, df)
rb = robust(res)
score = res["oos"]["ret"] + res["full"]["ret"]
cand = {
"asset": asset, "z": z_thr, "mb": mb,
"full": res["full"]["ret"], "oos": res["oos"]["ret"],
"fee02_oos": res["sweep_oos"][0.002],
"dd": res["full"]["dd"], "sharpe": res["full"]["sharpe"],
"pos_yrs": res["pos_yrs"], "n_yrs": res["n_yrs"],
"robust": rb, "score": score, "trades": res["full"]["trades"],
}
# preferisci robuste; a parita' di robustezza, score piu' alto
if best is None or (cand["robust"], cand["score"]) > (best["robust"], best["score"]):
best = cand
print("\n=== CELLA MIGLIORE ===")
if best:
print(f" asset={best['asset']} z={best['z']} mb={best['mb']} trades={best['trades']}")
print(f" FULL={best['full']:+.0f}% OOS={best['oos']:+.0f}% "
f"fee0.2%OOS={best['fee02_oos']:+.0f}% DD={best['dd']:.0f}% "
f"Sharpe={best['sharpe']:.2f} anniPos={best['pos_yrs']}/{best['n_yrs']} "
f"robust={best['robust']}")
print(f" no-look-ahead tutti gli asset: {'OK' if look_ok_all else 'VIOLATO'}")
return best
if __name__ == "__main__":
main()
+165
View File
@@ -0,0 +1,165 @@
"""Harness CONDIVISO per la ricerca dispersion/correlation index (crypto).
Feature CAUSALI (dalle sole close, nessun feed opzioni — la dispersion IMPLICITA
non e' backtestabile, muro ARGO/GEX documentato). Calcolate sull'universo comune
e allineabili a ogni singolo asset. Tutte note a close[i] (nessun look-ahead):
- avg_corr[W] : correlazione media a coppie dei log-rendimenti, rolling W (causale)
- disp[W] : dispersione cross-sectional (std cross-asset del rendimento di barra),
media rolling W
- idx_ret : rendimento dell'"indice" equal-weight (proxy mercato)
- beta_<A>[W] : beta rolling dell'asset A vs indice
- rel_<A> : rendimento di A meno rendimento indice (componente idiosincratica)
Uso dagli agenti di ricerca:
from scripts.analysis.dispersion_lab import features, align_to, UNIVERSE, COMMON_START
from scripts.analysis.explore_lab import get_df, evaluate, robust
F = features() # DataFrame indicizzato per timestamp(ms)
df = get_df("ETH", "1h")
fa = align_to(F, df) # feature riallineate alle barre di df (ffill causale)
# ... costruisci entries causali (entry decisa con dati <= close[i]) ...
res = evaluate("nome", entries, df); robust(res)
Check no-look-ahead: `python -m scripts.analysis.dispersion_lab` (perturba il futuro
e verifica che le feature fino a T non cambino).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df # noqa: E402
UNIVERSE = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
COMMON_START = "2022-07-22" # ultimo asset a entrare (LTC) -> universo completo
WINDOWS = [24, 72, 168, 336] # 1g, 3g, 1sett, 2sett in barre 1h
_CACHE: dict | None = None
def _panel():
"""{asset: close-series} allineato sui timestamp comuni dell'universo (1h)."""
frames = {}
for a in UNIVERSE:
d = get_df(a, "1h")
frames[a] = pd.Series(d["close"].values, index=d["timestamp"].values, name=a)
P = pd.concat(frames, axis=1).dropna()
P = P[P.index >= int(pd.Timestamp(COMMON_START, tz="UTC").timestamp() * 1000)]
return P
def _avg_pairwise_corr(R: np.ndarray, win: int) -> np.ndarray:
"""Media delle correlazioni a coppie dei log-rendimenti su finestra rolling.
CAUSALE: la riga i usa R[i-win+1 .. i]. Vettoriale via media/var rolling delle
scommatorie (corr di Pearson per coppia, poi media off-diagonale)."""
n, m = R.shape
out = np.full(n, np.nan)
# somme rolling per asset
df = pd.DataFrame(R)
s = df.rolling(win).sum().values # Σx
ss = (df * df).rolling(win).sum().values # Σx²
for i in range(win - 1, n):
w = R[i - win + 1:i + 1] # (win, m)
mean = s[i] / win
var = ss[i] / win - mean * mean
sd = np.sqrt(np.clip(var, 1e-18, None))
# matrice di covarianza della finestra
cov = (w.T @ w) / win - np.outer(mean, mean)
corr = cov / np.outer(sd, sd)
iu = np.triu_indices(m, k=1)
vals = corr[iu]
vals = vals[np.isfinite(vals)]
if vals.size:
out[i] = float(np.mean(vals))
return out
_CACHE_FILE = PROJECT_ROOT / "data" / "regime" / "dispersion_features.parquet"
def features(use_disk: bool = True) -> pd.DataFrame:
"""DataFrame indicizzato per timestamp(ms) con le feature causali. Cache di
processo + cache su disco (i molti agenti di ricerca la caricano invece di
ricalcolarla; la corr rolling e' costosa)."""
global _CACHE
if _CACHE is not None:
return _CACHE
if use_disk and _CACHE_FILE.exists():
_CACHE = pd.read_parquet(_CACHE_FILE)
return _CACHE
P = _panel()
logp = np.log(P.values)
R = np.vstack([np.zeros((1, P.shape[1])), np.diff(logp, axis=0)]) # log-ret per barra
R[0] = 0.0
idx_ret = R.mean(axis=1) # indice EW
out = pd.DataFrame(index=P.index)
out["idx_ret"] = idx_ret
# dispersione cross-sectional (std cross-asset del rendimento di barra) + medie rolling
xs = R.std(axis=1)
out["disp_bar"] = xs
for w in WINDOWS:
out[f"avg_corr_{w}"] = _avg_pairwise_corr(R, w)
out[f"disp_{w}"] = pd.Series(xs, index=P.index).rolling(w).mean().values
# componente idiosincratica e beta rolling vs indice (per ogni asset)
ir = pd.Series(idx_ret, index=P.index)
for k, a in enumerate(UNIVERSE):
ra = pd.Series(R[:, k], index=P.index)
out[f"rel_{a}"] = (ra - ir).values
for w in (72, 168):
cov = ra.rolling(w).cov(ir)
var = ir.rolling(w).var()
out[f"beta_{a}_{w}"] = (cov / var.replace(0, np.nan)).values
if use_disk:
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
out.to_parquet(_CACHE_FILE)
_CACHE = out
return out
def align_to(F: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame:
"""Riallinea le feature (indicizzate per ts comuni) alle barre di `df` (un asset),
con ffill CAUSALE (riempie in avanti: la feature a i usa l'ultima nota <= ts[i])."""
f = F.reindex(F.index.union(df["timestamp"].values)).sort_index().ffill()
return f.reindex(df["timestamp"].values).reset_index(drop=True)
def _check_no_lookahead() -> bool:
"""Perturba il FUTURO dei prezzi e verifica che le feature fino a T non cambino."""
global _CACHE
_CACHE = None
F0 = features().copy()
P = _panel()
T = int(len(P) * 0.6)
# perturbo le close DOPO T per tutti gli asset
P2 = P.copy()
P2.iloc[T + 1:] = P2.iloc[T + 1:] * 1.5
# ricostruisco le feature da P2 inline (stessa logica)
_CACHE = None
saved = globals()["_panel"]
globals()["_panel"] = lambda: P2
_CACHE = None
F1 = features()
globals()["_panel"] = saved
_CACHE = None
cols = [c for c in F0.columns if c.startswith(("avg_corr", "disp", "beta"))]
a = F0[cols].iloc[:T - max(WINDOWS)].values
b = F1[cols].iloc[:T - max(WINDOWS)].values
ok = np.allclose(np.nan_to_num(a), np.nan_to_num(b), atol=1e-9)
print(f"[no-look-ahead] feature fino a T={T} invarianti al futuro: {'OK' if ok else 'VIOLATO'}")
return ok
if __name__ == "__main__":
F = features()
P = _panel()
print(f"universo {UNIVERSE}")
print(f"finestra comune: {pd.to_datetime(P.index[0], unit='ms', utc=True).date()} "
f"-> {pd.to_datetime(P.index[-1], unit='ms', utc=True).date()} ({len(P)} barre)")
print(f"feature: {list(F.columns)}")
print(F[[f'avg_corr_{w}' for w in WINDOWS]].describe().round(3).to_string())
_check_no_lookahead()
+106
View File
@@ -0,0 +1,106 @@
"""Drift monitor per-famiglia — il rolling-return corrente di ogni famiglia vs la
DISTRIBUZIONE STORICA dei propri rolling-return (stessa finestra, storia 2021+).
Non è un filtro di trading: è OSSERVABILITÀ (la protezione giusta contro il drift è
accorgersene presto, non ritoccare i parametri — lezione 2026-06-11: le FADE al 2°
percentile sul 120g sono state trovate a mano; questo script lo rende ripetibile).
Percentile basso = la famiglia sta attraversando uno dei suoi tratti peggiori:
- sotto P_WARN (5%): segnalato — coerente con la coda storica, OSSERVARE;
- il PORT06 complessivo sotto P_WARN è più serio (la diversificazione non copre).
Equity dal builder canonico (all_sleeve_equities → parità coi gate).
uv run python scripts/analysis/drift_monitor.py # stampa
uv run python scripts/analysis/drift_monitor.py --telegram # + invio Telegram
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import port_returns
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
WINDOWS = (60, 120) # giorni
P_WARN = 5.0 # percentile sotto cui segnalare
def family_returns():
"""Rendimenti daily per famiglia (equal-weight intra-famiglia) + PORT06 (pesi cap)."""
p = PORTFOLIOS["PORT06"]
eq = dict(all_sleeve_equities())
ids = list(p.sleeve_ids)
fams: dict[str, list] = {}
for i in ids:
fams.setdefault(W.family_of(i), []).append(i)
out = {}
for f, members in sorted(fams.items()):
out[f] = port_returns({i: eq[i] for i in members},
{i: 1 / len(members) for i in members})
dr = pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector("cap", ids, dr, caps=p.caps, clusters=p.clusters)
out["PORT06"] = port_returns({i: eq[i] for i in ids}, w)
return out
def drift_rows():
rows = []
for name, r in family_returns().items():
for win in WINDOWS:
# vettoriale (log1p+rolling sum) invece di apply(np.prod): identico
# numericamente, ~100x piu' veloce del callback Python per-finestra
roll = np.expm1(np.log1p(r).rolling(win).sum())
roll = roll.dropna()
if len(roll) < 100:
continue
cur = float(roll.iloc[-1])
pct = float((roll < cur).mean() * 100)
rows.append(dict(name=name, win=win, cur=cur * 100, pct=pct,
p5=float(roll.quantile(0.05) * 100),
med=float(roll.median() * 100)))
return rows
def build_report(rows) -> tuple[str, bool]:
warn = [r for r in rows if r["pct"] < P_WARN]
L = ["📉 <b>Drift monitor</b> — rolling-return vs storia propria (2021+)"]
L.append("<pre>" + f"{'famiglia':<9}{'win':>5}{'corr%':>8}{'pct':>6}{'p5%':>8}{'med%':>7}")
for r in rows:
flag = " ⚠️" if r["pct"] < P_WARN else ""
L.append(f"{r['name']:<9}{r['win']:>4}g{r['cur']:>+8.1f}{r['pct']:>5.0f}%"
f"{r['p5']:>+8.1f}{r['med']:>+7.1f}{flag}")
L.append("</pre>")
if warn:
names = ", ".join(f"{r['name']} {r['win']}g (p{r['pct']:.0f})" for r in warn)
L.append(f"⚠️ sotto il p{P_WARN:.0f} storico: {names} — coda storica della famiglia: "
"OSSERVARE, non ritoccare i parametri (drift ≠ rottura; "
"vedi docs/diary/2026-06-11-stability-sweep.md)")
else:
L.append(f"✅ tutte le famiglie sopra il p{P_WARN:.0f} storico")
return "\n".join(L), bool(warn)
def main():
rows = drift_rows()
report, warned = build_report(rows)
import re
print(re.sub(r"</?(b|pre)>", "", report))
if "--telegram" in sys.argv:
from src.live.telegram_notifier import send_telegram
ok = send_telegram(report)
print(f"[telegram] inviato: {ok}")
return warned
if __name__ == "__main__":
# exit code 1 su warn: utilizzabile da cron/script come canale d'allarme
# (coerente con reconcile_account; prima il bool era calcolato e buttato via)
sys.exit(1 if main() else 0)
+126
View File
@@ -0,0 +1,126 @@
"""Gate del CATASTROPHE-CAP auto-finanziato (collar standing) sullo sleeve ETH no-SL.
Tesi: lo sleeve ETH no-SL ha la coda da crash (un long-fade puo' perdere -50/-65% in un
gap). Un COLLAR standing rollato mensilmente — put lunga ~13% OTM finanziata da call corta
~10% OTM — cappa quella coda a premio netto ~zero (validato sui premi REALI di cerbero-bite:
put -13%≈1.0%/m IV55, call +10%≈1.05%/m IV49). Pricing BS calibrato sul reale: skew_put 1.12,
skew_call 1.0. Caveat: il collar aggiunge delta SHORT-ETH con dead-zone -p/+c -> cappa anche
l'upside; nei mesi tranquilli (ETH dentro la banda) costa ~zero. Il gate dice se aiuta netto.
uv run python scripts/analysis/eth_collar_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df
from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns, _norm
from scripts.analysis.option_overlay_lab import dvol_for, bs_put, bs_call
from scripts.analysis.mr02eth_port06_gate import (
gen_donchian_base, build_trades, build_trades_exit16, daily_equity, port_metrics, CAPS)
from src.portfolio.sleeves import all_sleeve_equities
HY = 24 * 365.0
def collar_daily_returns(df, dvol, p_otm=0.13, c_otm=0.10, skew_put=1.12, skew_call=1.0,
roll_h=24 * 30) -> pd.Series:
"""Collar standing rollato ogni roll_h ore. Ritorna la SERIE di rendimenti GIORNALIERI
(frazione del notional collar): MTM = d(intrinseco) - theta (premio netto amortizzato)."""
c = df["close"].values; ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
n = len(c)
val = np.zeros(n) # valore collar (frac notional) marcato a intrinseco - premio residuo
T = roll_h / HY
k = 0
while k < n - 1:
S0 = c[k]; sig = dvol[k] if not np.isnan(dvol[k]) else 0.6
Kp = S0 * (1 - p_otm); Kc = S0 * (1 + c_otm)
prem = bs_put(S0, Kp, T, sig * skew_put) / S0 - bs_call(S0, Kc, T, sig * skew_call) / S0
end = min(k + roll_h, n - 1)
span = end - k
for j in range(k, end + 1):
intr = (max(Kp - c[j], 0.0) - max(c[j] - Kc, 0.0)) / S0
frac_elapsed = (j - k) / span if span else 1.0
val[j] = intr - prem * (1 - frac_elapsed) # premio pagato up-front, amortizzato a 0 a scadenza
k = end
s = pd.Series(val, index=ts)
daily = s.resample("1D").last().reindex(IDX).ffill().bfill()
return daily.diff().fillna(0.0) # rendimento giornaliero (frac notional)
def combine(fade_eq: pd.Series, collar_dr: pd.Series, hedge_frac: float) -> pd.Series:
"""sleeve = fade no-SL + hedge_frac * collar. Combina i rendimenti giornalieri."""
fr = fade_eq.pct_change().fillna(0.0)
return _norm((1 + fr + hedge_frac * collar_dr).cumprod())
def crash_audit(df, dvol, p_otm, c_otm, hedge_frac):
"""P&L del collar nei mesi di crollo ETH peggiori (frac notional)."""
cr = collar_daily_returns(df, dvol, p_otm=p_otm, c_otm=c_otm)
# ETH daily ret mensile
cdf = pd.Series(df["close"].values, index=pd.to_datetime(df["timestamp"], unit="ms", utc=True)).resample("1D").last().reindex(IDX).ffill()
mret = cdf.resample("30D").last().pct_change()
collar_m = (1 + cr).resample("30D").apply(lambda x: x.prod()) - 1
worst = mret.nsmallest(5)
print(f" {'mese (fine)':>12}{'ETH 30g%':>10}{'collar P&L%':>13}")
for t, r in worst.items():
cm = collar_m.reindex([t], method="nearest").iloc[0] * hedge_frac * 100
print(f" {str(t.date()):>12}{r*100:>9.0f}%{cm:>12.1f}%")
def main():
print("=" * 96)
print(f" GATE collar standing (catastrophe-cap) sullo sleeve ETH no-SL | OOS da {OOS_DATE}")
print("=" * 96)
df = get_df("ETH", "1h"); dvol = dvol_for(df, "ETH")
eq = dict(all_sleeve_equities())
ids = [k for k in eq if k in {"MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH",
"DIP01_BTC","TR01_basket","ROT02_rot","PR_ETHBTC","PR_LTCETH","PR_ADAETH","PR_BTCLTC","PR_ETHSOL","TSM01","SH_BTC","SH_ETH"}]
base_ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0)
nosl_ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False)
def pm(ce):
m = dict(eq); m["MR02_ETH"] = ce; return port_metrics(m, ids)
f_l, o_l = pm(daily_equity(build_trades_exit16(base_ents, df, sl_confirm=0.5), df))
fade_nosl = daily_equity(build_trades(nosl_ents, df), df)
f0, o0 = pm(fade_nosl)
print(f"\n {'sleeve ETH':<30s}{'FULL Sh':>8s}{'FULL DD':>8s} |{'OOS Sh':>8s}{'OOS DD':>8s}")
print(" " + "-" * 78)
print(f" {'LIVE EXIT-16 (rif)':<30s}{f_l['sharpe']:>8.2f}{f_l['dd']:>8.2f} |{o_l['sharpe']:>8.2f}{o_l['dd']:>8.2f}")
print(f" {'no-SL nudo':<30s}{f0['sharpe']:>8.2f}{f0['dd']:>8.2f} |{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
configs = [
("put13/call10 hf0.45", 0.13, 0.10, 0.45),
("put13/call10 hf0.30", 0.13, 0.10, 0.30),
("put15/call12 hf0.45", 0.15, 0.12, 0.45),
("put20/call15 hf0.45", 0.20, 0.15, 0.45),
("put13/call08 hf0.45", 0.13, 0.08, 0.45),
]
rows = []
for name, p, cc, hf in configs:
cr = collar_daily_returns(df, dvol, p_otm=p, c_otm=cc)
ce = combine(fade_nosl, cr, hf)
f_c, o_c = pm(ce)
rows.append((name, p, cc, hf, f_c, o_c))
print(f" {'no-SL + '+name:<30s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f} |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}")
print("\n " + "=" * 90)
print(f" vs LIVE EXIT-16 (FULL {f_l['sharpe']:.2f}/{f_l['dd']:.2f} OOS {o_l['sharpe']:.2f}/{o_l['dd']:.2f}) e vs no-SL")
print(" " + "-" * 90)
for name, p, cc, hf, f_c, o_c in rows:
print(f" {name:<22s} Δ vsEXIT16 FULL {f_c['sharpe']-f_l['sharpe']:+.2f}/{f_c['dd']-f_l['dd']:+.2f} "
f"OOS {o_c['sharpe']-o_l['sharpe']:+.2f}/{o_c['dd']-o_l['dd']:+.2f} | "
f"Δ vsNoSL DD {f_c['dd']-f0['dd']:+.2f}")
print("\n --- audit crash: P&L collar (hf-scaled) nei 5 mesi ETH peggiori (put13/call10 hf0.45) ---")
crash_audit(df, dvol, 0.13, 0.10, 0.45)
if __name__ == "__main__":
main()
@@ -0,0 +1,143 @@
"""TEST DECISIVO: impatto di EXIT-16 (close_confirm_sl, buffer=0.5 ATR) sul PORT06,
nel PATH CANONICO del backtest (NON exit_lab).
EXIT-16: lo SL intrabar e' DISATTIVATO; si esce al close del bar j solo se il close
ha sfondato il livello di buffer*ATR14:
long (d=1): esci a close[j] se close[j] < sl0 - 0.5*atr14[j]
short (d=-1): esci a close[j] se close[j] > sl0 + 0.5*atr14[j]
TP intrabar al livello e max_bars al close restano INVARIATI.
Metodo (come fu fatto per il loss-guard Hurst):
1. build_everything() canonico -> equity giornaliere di TUTTI gli sleeve (cache intatta).
2. ricostruisco le 6 equity fade in variante EXIT-16 replicando ESATTAMENTE
fade_daily_equity/build_trades (stessi segnali fn(df,**params), trend_max=3.0,
fee 0.10%RT*lev3, pos 0.15, compounding, non-overlap), cambiando SOLO il ramo SL.
3. PARITA': con la SL-rule originale il replay deve riprodurre le equity canoniche.
4. PORT06 base vs EXIT-16 con la STESSA matematica dei pesi (Portfolio.backtest):
weighting cap, caps PAIRS 0.33, ribilancio 1D, metriche FULL e OOS.
NB: la leva 2x del portfolios.yml NON entra nel backtest (Portfolio.backtest la ignora;
e' un knob live). Le equity fade gia' includono lev=3 dentro build_trades.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from scripts.analysis.risk_management import strats_for
from scripts.analysis.combine_portfolio import OOS_DATE
from scripts.analysis._port06_gate_common import (
build_trades_variant, equity_from_trades, port_metrics, dd as _dd,
)
from scripts.portfolios._defs import PORTFOLIOS
def fade_equity_variant(asset, fn, params, mode):
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
df = load_data(asset, "1h")
trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
return equity_from_trades(df, trades)
def main():
p = PORTFOLIOS["PORT06"]
fade_ids = [s.sid for s in p.sleeves if s.sid.startswith("MR")]
print("=" * 96)
print(" TEST DECISIVO EXIT-16 (close_confirm_sl buffer=0.5 ATR) su PORT06 — path canonico")
print(f" fade sleeve: {fade_ids}")
print("=" * 96)
# --- 1. equity canoniche di TUTTI gli sleeve (cache intatta) ---
print("\n[1] build_everything() canonico (pesante, ~2-3 min)...")
from src.portfolio.sleeves import all_sleeve_equities
eq_base = dict(all_sleeve_equities()) # {sid: equity giornaliera}
print(f" sleeve totali: {len(eq_base)}")
# --- 2. PARITA': replay 'orig' deve riprodurre le equity canoniche ---
print("\n[2] PARITA' replay (mode=orig) vs canonico (fade_daily_equity):")
print(f" {'sleeve':<10s}{'corr':>10s}{'ret_canon%':>14s}{'ret_replay%':>14s}{'diff%':>9s}")
parity_ok = True
eq_orig, eq_e16 = {}, {}
for asset in ("BTC", "ETH"):
for nm, (fn, params) in strats_for(asset).items():
sid = f"{nm}_{asset}"
if sid not in fade_ids:
continue
eq_orig[sid] = fade_equity_variant(asset, fn, params, mode="orig")
eq_e16[sid] = fade_equity_variant(asset, fn, params, mode="exit16")
base = eq_base[sid]
rep = eq_orig[sid]
corr = base.pct_change().fillna(0).corr(rep.pct_change().fillna(0))
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
rr = (rep.iloc[-1] / rep.iloc[0] - 1) * 100
diff = rr - rb
flag = "" if (corr > 0.999 and abs(diff) <= max(1.0, abs(rb) * 0.01)) else " <-- MISMATCH"
if flag:
parity_ok = False
print(f" {sid:<10s}{corr:>10.5f}{rb:>14.1f}{rr:>14.1f}{diff:>+9.2f}{flag}")
print(f"\n PARITA' {'OK' if parity_ok else 'FALLITA'} "
f"(corr>0.999 e ret finale entro 1%).")
if not parity_ok:
print("\n >>> Parita' non raggiunta: NON forzo. Diagnostico sopra. STOP.")
return
# --- 3. PORT06 base vs EXIT-16: stessi pesi cap, stessa matematica ---
members_base = dict(eq_base)
members_e16 = dict(eq_base)
for sid in fade_ids:
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
# pesi cap canonici (gli stessi che usa Portfolio.backtest) dentro port_metrics
f_b, o_b = port_metrics(members_base, p)
f_e, o_e = port_metrics(members_e16, p)
print("\n" + "=" * 96)
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
print("=" * 96)
print(f" {'variante':<14s}{'FULL Sh':>9s}{'FULL DD%':>10s}{'FULL CAGR':>11s}"
f" | {'OOS Sh':>8s}{'OOS DD%':>9s}{'OOS CAGR':>10s}")
print(" " + "-" * 90)
print(f" {'BASE':<14s}{f_b['sharpe']:>9.2f}{f_b['dd']:>10.2f}{f_b['cagr']:>10.0f}%"
f" | {o_b['sharpe']:>8.2f}{o_b['dd']:>9.2f}{o_b['cagr']:>9.0f}%")
print(f" {'EXIT-16':<14s}{f_e['sharpe']:>9.2f}{f_e['dd']:>10.2f}{f_e['cagr']:>10.0f}%"
f" | {o_e['sharpe']:>8.2f}{o_e['dd']:>9.2f}{o_e['cagr']:>9.0f}%")
print(" " + "-" * 90)
print(f" {'DELTA':<14s}{f_e['sharpe']-f_b['sharpe']:>+9.2f}{f_e['dd']-f_b['dd']:>+10.2f}"
f"{f_e['cagr']-f_b['cagr']:>+10.0f}% | {o_e['sharpe']-o_b['sharpe']:>+8.2f}"
f"{o_e['dd']-o_b['dd']:>+9.2f}{o_e['cagr']-o_b['cagr']:>+9.0f}%")
# --- per-sleeve fade: differenze principali ---
print("\n Per-sleeve fade (equity FULL ret%, EXIT-16 vs orig-replay):")
print(f" {'sleeve':<10s}{'orig ret%':>12s}{'exit16 ret%':>14s}{'delta%':>10s}"
f"{'orig DD%':>10s}{'e16 DD%':>10s}")
for sid in fade_ids:
ro = eq_orig[sid]; re = eq_e16[sid]
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
f"{_dd(ro):>10.1f}{_dd(re):>10.1f}")
# --- GATE ---
print("\n" + "=" * 96)
print(" GATE (stesso del loss-guard): PROMOSSO se OOS Sharpe migliora/pari E DD non peggiora")
print(" materialmente, E in FULL non degrada.")
print("=" * 96)
oos_sh_ok = o_e['sharpe'] >= o_b['sharpe'] - 0.02
oos_dd_ok = o_e['dd'] <= o_b['dd'] + 0.20 # no peggioramento materiale DD
full_ok = f_e['sharpe'] >= f_b['sharpe'] - 0.02 and f_e['dd'] <= f_b['dd'] + 0.20
promoted = oos_sh_ok and oos_dd_ok and full_ok
print(f" OOS Sharpe {o_b['sharpe']:.2f} -> {o_e['sharpe']:.2f} "
f"({'OK' if oos_sh_ok else 'KO'})")
print(f" OOS DD% {o_b['dd']:.2f} -> {o_e['dd']:.2f} "
f"({'OK' if oos_dd_ok else 'KO'})")
print(f" FULL Sharpe {f_b['sharpe']:.2f} -> {f_e['sharpe']:.2f} | "
f"FULL DD {f_b['dd']:.2f} -> {f_e['dd']:.2f} ({'OK' if full_ok else 'KO'})")
print("\n VERDETTO: " + (">>> PROMOSSO <<<" if promoted else ">>> BOCCIATO <<<"))
if __name__ == "__main__":
main()
+259
View File
@@ -0,0 +1,259 @@
"""EXIT LAB — harness onesto e CONDIVISO per la ricerca di policy di uscita
(TP dinamico, SL dinamico/trailing, partial, ride) sulle fade attive.
Ricerca 2026-06-04 (>=20 agenti): ogni agente implementa una ExitPolicy in
scripts/analysis/exit_policies/<id>_<nome>.py e la valuta QUI, sugli STESSI
segnali (cache su disco) e con lo stesso engine intrabar di fade_base.
CONTRATTO ANTI-LOOK-AHEAD (vincolante, verra' verificato da agenti avversari):
- i livelli attivi nel bar j (`levels(j)`) possono usare SOLO dati <= j-1
(il worker live li fissa al close del bar precedente, poi il bar j li tocca);
- `after_bar(j)` decide sul CLOSE del bar j (eseguibile al poll del tick);
- indicatori: usare l'indice j-1 degli array causali (es. ctx["atr14"][j-1]).
PROTOCOLLO ANTI-OVERFIT (vincolante):
- TRAIN = storico fino al 2023-11-01, OOS = dopo. La SELEZIONE dei parametri
si fa SOLO sul train; l'OOS si guarda una volta, per il verdetto.
- gate: il miglioramento deve tenere su ENTRAMBI gli asset e su TUTTE e 3 le
strategie (train E oos), con plateau sulla griglia (non una cella isolata).
- fee 0.10% RT x leva su tutto il notional; nessuna fee scontata sui limit.
Baseline = exit attuale (TP/SL fissi dall'entrata + max_bars): la parita' con
`partial_tp_ladder.py --base` e' verificata da `parity_check()`.
uv run python scripts/analysis/exit_lab.py # build cache + parity check
"""
from __future__ import annotations
import pickle
import sys
from pathlib import Path
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 # noqa: E402
from src.live.strategy_loader import load_strategy # noqa: E402
LIVE_PARAMS = dict(trend_max=3.0, ema_long=200, hurst_max=0.55, min_tp_frac=0.0015)
OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6)
LEV, POS, FEE_RT = 3.0, 0.15, 0.001
CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"]
ASSETS = ("BTC", "ETH")
CACHE = PROJECT_ROOT / "data" / "cache" / "exit_lab_signals.pkl"
HARD_CAP = 240 # bound assoluto ai bar in posizione (policy "ride" comprese)
# ----------------------------------------------------------------------------- dati
def _atr14(h: np.ndarray, l: np.ndarray, c: np.ndarray) -> np.ndarray:
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(14).mean().values
def load_sleeves(refresh: bool = False) -> dict:
"""{(code, asset): sleeve} con cache. sleeve = {signals, open, high, low,
close, ts_ms, atr14}. signals = [(i, d, tp0, sl0, mb), ...] dai params LIVE."""
if CACHE.exists() and not refresh:
with open(CACHE, "rb") as f:
return pickle.load(f)
out = {}
for code in CODES:
strat = load_strategy(code)
for asset in ASSETS:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **LIVE_PARAMS)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
out[(code, asset)] = {
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
for s in sigs],
"open": df["open"].values.astype(float),
"high": h, "low": l, "close": c,
"ts_ms": df["timestamp"].values.astype(np.int64),
"atr14": _atr14(h, l, c),
}
print(f" cache {code} {asset}: {len(sigs)} segnali, {len(c)} barre "
f"({ts.iloc[0].date()} -> {ts.iloc[-1].date()})")
CACHE.parent.mkdir(parents=True, exist_ok=True)
with open(CACHE, "wb") as f:
pickle.dump(out, f)
return out
# ----------------------------------------------------------------------------- policy
class ExitPolicy:
"""Baseline = exit live attuale. Le sottoclassi ridefinisco levels/after_bar.
Una ISTANZA per trade. `ctx` e' il dict sleeve (array completi + indicatori
aggiunti da prepare()): per contratto si legge SOLO fino a j-1 in levels(j)
e fino a j in after_bar(j)/on_partial(j).
"""
name = "base"
@classmethod
def prepare(cls, ctx: dict, **params) -> None:
"""Pre-calcola array causali per-sleeve (una volta), es. SMA/EMA."""
def __init__(self, ctx: dict, i: int, d: int, entry: float,
tp0: float, sl0: float, mb: int, **params):
self.ctx, self.i, self.d, self.entry = ctx, i, d, entry
self.tp0, self.sl0, self.mb = tp0, sl0, mb
self.horizon = mb # le sottoclassi possono estendere (cap HARD_CAP)
def levels(self, j: int):
"""Livelli ATTIVI nel bar j -> (tp, sl, tp_frac). None = livello assente.
tp_frac = quota del RESIDUO che esce al tocco del TP (1.0 = tutta)."""
return self.tp0, self.sl0, 1.0
def on_partial(self, j: int, price: float, remaining: float) -> None:
"""Notifica del fill parziale al TP nel bar j (aggiorna lo stato qui)."""
def after_bar(self, j: int) -> bool:
"""True = chiudi il residuo al close[j] (decisione sul close, eseguibile)."""
return False
# ----------------------------------------------------------------------------- engine
def simulate(policy_cls, sleeve: dict, params: dict | None = None,
start_ms: int | None = None, end_ms: int | None = None) -> dict:
"""Replay intrabar dei segnali dello sleeve con la policy. SL prioritario
sul TP nello stesso bar (conservativo); fill parziali pesati; max_bars/
horizon esce al close; non-overlap (una posizione per volta)."""
params = params or {}
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = FEE_RT * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
bars_tot = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills: list[tuple[float, float]] = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl: # conservativo: SL prima del TP
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9: # safety (non dovrebbe accadere)
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {
"ret_pct": (capital / 1000.0 - 1) * 100,
"dd_pct": max_dd * 100,
"trades": trades,
"win_pct": wins / trades * 100,
"avg_ret_bps": r.mean() * 1e4,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades,
}
# ----------------------------------------------------------------------------- report
def evaluate(policy_cls, grid: list[dict], data: dict | None = None,
quiet: bool = False) -> dict:
"""Protocollo train/OOS su tutta la griglia. La selezione dei parametri va
fatta SUL TRAIN (l'OOS si riporta, non si ottimizza). Ritorna dict
{params_str: {sleeve: {train: {...}, oos: {...}}}} + baseline."""
data = data or load_sleeves()
out: dict = {}
rows = [("base", ExitPolicy, {})] + [
(", ".join(f"{k}={v}" for k, v in g.items()) or "default", policy_cls, g)
for g in grid]
for tag, cls, g in rows:
out[tag] = {}
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
tr = simulate(cls, sleeve, g, end_ms=OOS_START_MS)
oo = simulate(cls, sleeve, g, start_ms=OOS_START_MS)
out[tag][key] = {"train": tr, "oos": oo}
if not quiet:
print(f"{tag:<28}{key:<10}"
f"TRAIN ret{tr.get('ret_pct', 0):>7.0f}% dd{tr.get('dd_pct', 0):>5.1f} "
f"sh{tr.get('sharpe_t', 0):>5.2f} n{tr.get('trades', 0):>4} | "
f"OOS ret{oo.get('ret_pct', 0):>6.0f}% dd{oo.get('dd_pct', 0):>5.1f} "
f"sh{oo.get('sharpe_t', 0):>5.2f} n{oo.get('trades', 0):>4} "
f"bars{oo.get('avg_bars', 0):>5.1f}")
return out
def parity_check() -> None:
"""La baseline qui deve riprodurre i numeri FULL di partial_tp_ladder (base):
MR01 BTC ~92%/13.8dd, MR01 ETH ~194%/16.5dd, MR02 ETH ~2135%/16.2dd..."""
data = load_sleeves()
print("\nParity check baseline (FULL, atteso = partial_tp_ladder base):")
expected = {("MR01_bollinger_fade", "BTC"): 92, ("MR01_bollinger_fade", "ETH"): 194,
("MR02_donchian_fade", "BTC"): 129, ("MR02_donchian_fade", "ETH"): 2135,
("MR07_return_reversal", "BTC"): 78, ("MR07_return_reversal", "ETH"): 115}
ok = True
for key, sleeve in data.items():
r = simulate(ExitPolicy, sleeve)
exp = expected[key]
match = abs(r["ret_pct"] - exp) < 1.0
ok &= match
print(f" {key[0].split('_')[0]} {key[1]}: ret {r['ret_pct']:.0f}% "
f"(atteso ~{exp}) {'OK' if match else 'MISMATCH'}")
print("PARITY", "OK" if ok else "FAILED")
if __name__ == "__main__":
load_sleeves(refresh="--refresh" in sys.argv)
parity_check()
@@ -0,0 +1,75 @@
"""EXIT-01 — trail_atr_ride: TP RIMOSSO, cavalcata pura con SL trailing chandelier.
Idea: le fade mean-reversion escono oggi al TP fisso (alla media) + SL + max_bars.
Qui togliamo il TP e lasciamo correre il trade, proteggendolo con un SL trailing
"chandelier" a k*ATR dal massimo favorevole raggiunto. Lo stop puo' solo stringersi
(mai allargarsi). Orizzonte esteso (cap HARD_CAP=240) per dare spazio al runner.
Long: stop(j) = max( sl0, max(high[i..j-1]) - k*atr14[j-1] ) (sale, mai scende)
Short: stop(j) = min( sl0, min(low[i..j-1]) + k*atr14[j-1] ) (scende, mai sale)
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- massimo/minimo favorevole sullo slice [i .. j-1] (mantenuto incrementalmente,
aggiornato col bar j-1 prima di calcolare lo stop attivo nel bar j);
- atr14[j-1] (indice causale).
Nessun TP -> nessun fill parziale. after_bar non usato (chiusura solo a orizzonte/SL).
GRID: k in {2.0, 3.0, 4.0} x horizon_mult in {2, 4} (6 celle). horizon = mult*mb cap 240.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP
class TrailAtrRide(ExitPolicy):
name = "trail_atr_ride"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=3.0, horizon_mult=4, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(k)
self.horizon = min(int(horizon_mult) * mb, HARD_CAP)
# estremo favorevole sullo slice [i..j-1]; inizializzato al bar di entrata i
# (il primo bar valutato e' j=i+1, dove lo slice [i..j-1]=[i..i] e' noto).
self.fav_high = ctx["high"][i]
self.fav_low = ctx["low"][i]
self._last_seen = i # ultimo indice gia' incorporato nell'estremo
# stop trailing monotono: parte da sl0 e puo' solo stringersi
self.cur_stop = sl0
def levels(self, j):
h = self.ctx["high"]
l = self.ctx["low"]
atr = self.ctx["atr14"]
# incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
if h[self._last_seen] > self.fav_high:
self.fav_high = h[self._last_seen]
if l[self._last_seen] < self.fav_low:
self.fav_low = l[self._last_seen]
a = atr[j - 1]
if a != a: # NaN nei primi 14 bar -> resta sullo stop corrente
return None, self.cur_stop, 1.0
if self.d == 1:
cand = self.fav_high - self.k * a
if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
self.cur_stop = cand
else:
cand = self.fav_low + self.k * a
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE (stringersi)
self.cur_stop = cand
return None, self.cur_stop, 1.0 # TP rimosso
GRID = [
{"k": k, "horizon_mult": m}
for k in (2.0, 3.0, 4.0)
for m in (2, 4)
]
if __name__ == "__main__":
evaluate(TrailAtrRide, GRID)
@@ -0,0 +1,68 @@
"""EXIT-02 — trail_atr_keep_tp.
Chandelier trailing stop a k*ATR dall'estremo favorevole RAGGIUNTO dall'entrata,
MA il TP fisso tp0 dall'entrata RESTA attivo: si esce al PRIMO dei due (il TP al
livello, oppure il trail). horizon = max_bars (invariato).
Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead):
long : chand = max(high[i..j-1]) - k*atr14[j-1]; sl = max(sl0, chand)
short: chand = min(low[i..j-1]) + k*atr14[j-1]; sl = min(sl0, chand)
Il max(sl0, chand) (per il long) tiene la protezione iniziale a sl0 e lascia che
il trail TIGHTEN solo quando il prezzo corre a favore -> "ride" controllato che
non allenta mai il rischio iniziale. Il TP non viene toccato: una fade che torna
alla media esce comunque al TP come la base; il trail morde solo quando il TP non
viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato.
GRID: k in {1.5, 2.0, 3.0, 4.0} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class TrailATRKeepTP(ExitPolicy):
name = "trail_atr_keep_tp"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(params.get("k", 2.0))
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# estremo favorevole running (solo barre <= j-1); init = barra d'entrata i
self.run_hi = self.high[i]
self.run_lo = self.low[i]
self.last_seen = i # ultimo indice gia' incorporato nel running extremum
def _update_running(self, upto: int) -> None:
"""Incorpora le barre (last_seen, upto] nell'estremo running. upto = j-1,
quindi NON tocca il bar j (anti-look-ahead)."""
while self.last_seen < upto:
self.last_seen += 1
if self.high[self.last_seen] > self.run_hi:
self.run_hi = self.high[self.last_seen]
if self.low[self.last_seen] < self.run_lo:
self.run_lo = self.low[self.last_seen]
def levels(self, j: int):
self._update_running(j - 1) # solo dati <= j-1
a = self.atr[j - 1]
if a is None or a != a: # NaN -> nessun trail, usa sl0
return self.tp0, self.sl0, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl0, chand) # piu' protettivo (stop piu' alto)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl0, chand) # piu' protettivo (stop piu' basso)
return self.tp0, sl, 1.0
GRID = [{"k": 1.5}, {"k": 2.0}, {"k": 3.0}, {"k": 4.0}]
if __name__ == "__main__":
evaluate(TrailATRKeepTP, GRID)
@@ -0,0 +1,85 @@
"""EXIT-03 — trail_pct: trailing percentuale dall'high-water-mark favorevole (sui CLOSE).
Idea: invece di un trail ATR (EXIT-01/02), lo stop segue l'estremo favorevole
RAGGIUNTO dai CLOSE a una distanza percentuale fissa p. Lo stop puo' solo
stringersi (mai allentarsi sotto sl0 lato rischio).
Long : hwm = max(close[i..j-1]); stop(j) = max(sl0, hwm*(1-p)) (sale, mai scende)
Short: lwm = min(close[i..j-1]); stop(j) = min(sl0, lwm*(1+p)) (scende, mai sale)
Due varianti:
keep_tp=True -> il TP fisso tp0 dall'entrata RESTA attivo (esci al primo dei due),
horizon = max_bars (invariato). Il trail morde solo se il TP non
viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato.
keep_tp=False -> TP RIMOSSO (cavalcata pura), horizon = 2*max_bars (cap HARD_CAP).
Si esce solo sul trailing-stop o a orizzonte.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- hwm/lwm sullo slice [i..j-1] dei CLOSE (mantenuto incrementalmente col bar j-1);
- p e' una costante -> nessuna dipendenza da indicatori del bar j.
L'hwm sui close (non sugli high) e' deliberato: il close e' il dato su cui il
worker live decide al poll del tick; un hwm sugli high anticiperebbe un livello
che il close non ha ancora confermato.
GRID: p in {0.005, 0.01, 0.02} x keep_tp in {True, False} (6 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class TrailPct(ExitPolicy):
name = "trail_pct"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb,
p=0.01, keep_tp=True, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.p = float(p)
self.keep_tp = bool(keep_tp)
if not self.keep_tp:
self.horizon = min(2 * mb, HARD_CAP)
self.close = ctx["close"]
# high/low-water-mark sui CLOSE, solo barre <= j-1; init = close d'entrata i
self.hwm = self.close[i]
self.lwm = self.close[i]
self.last_seen = i # ultimo indice gia' incorporato
# stop trailing monotono: parte da sl0 e puo' solo stringersi
self.cur_stop = sl0
def _update_wm(self, upto: int) -> None:
"""Incorpora le barre (last_seen, upto] nell'estremo sui close. upto = j-1
-> NON tocca il bar j (anti-look-ahead)."""
while self.last_seen < upto:
self.last_seen += 1
cv = self.close[self.last_seen]
if cv > self.hwm:
self.hwm = cv
if cv < self.lwm:
self.lwm = cv
def levels(self, j: int):
self._update_wm(j - 1) # solo dati <= j-1
if self.d == 1:
cand = self.hwm * (1.0 - self.p)
if cand > self.cur_stop: # lo stop long puo' solo SALIRE
self.cur_stop = cand
else:
cand = self.lwm * (1.0 + self.p)
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE
self.cur_stop = cand
tp = self.tp0 if self.keep_tp else None
return tp, self.cur_stop, 1.0
GRID = [
{"p": p, "keep_tp": kt}
for p in (0.005, 0.01, 0.02)
for kt in (True, False)
]
if __name__ == "__main__":
evaluate(TrailPct, GRID)
@@ -0,0 +1,74 @@
"""EXIT-04 — breakeven (protezione).
Quando il profitto su CLOSE supera k*ATR(entry) (ATR fissato all'entrata,
atr14[i]), sposta lo SL a BREAKEVEN = entry +/- buffer. Il buffer e' a favore
(0.2% = 2*fee_rt*entry) cosi' l'uscita a BE non e' in perdita per le fee. Il TP
fisso tp0 RESTA invariato. horizon = max_bars (invariato).
Una volta armato il breakeven, lo SL non torna mai indietro (cliquet): protegge
il profitto gia' maturato senza allentare il rischio.
Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead):
- trigger: si arma quando close[j-1] e' a favore di >= k*atr14[i] dall'entrata
(atr14[i] = ATR all'entrata, costante; close[j-1] = ultimo close noto).
- prima dell'arm: sl = sl0 (protezione iniziale invariata).
- dopo l'arm:
long : be = entry + buffer; sl = max(sl0, be)
short: be = entry - buffer; sl = min(sl0, be)
NB max/min con sl0 fa si' che lo SL non venga mai ALLENTATO: se sl0 fosse
gia' oltre il BE (raro), resta sl0. Tipicamente sl0 e' sotto entry (long) e
il BE lo alza -> stop piu' protettivo.
GRID: k in {0.5, 1.0, 1.5} x buffer_frac in {0.0, 0.002} (6 celle).
buffer = buffer_frac * entry. buffer_frac 0.002 = 0.2% = 2*fee_rt a favore.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class Breakeven(ExitPolicy):
name = "breakeven"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(params.get("k", 1.0))
self.buffer_frac = float(params.get("buffer_frac", 0.002))
self.close = ctx["close"]
self.atr = ctx["atr14"]
self.atr_entry = self.atr[i] # ATR all'entrata, costante
self.buffer = self.buffer_frac * entry
self.armed = False
def levels(self, j: int):
# arma il breakeven usando SOLO close[j-1] (ultimo close noto) e atr14[i]
if not self.armed and self.atr_entry is not None and self.atr_entry == self.atr_entry:
cprev = self.close[j - 1]
profit = (cprev - self.entry) * self.d # >0 = a favore
if profit >= self.k * self.atr_entry:
self.armed = True
if not self.armed:
return self.tp0, self.sl0, 1.0
if self.d == 1:
be = self.entry + self.buffer
sl = max(self.sl0, be) # non allenta mai
else:
be = self.entry - self.buffer
sl = min(self.sl0, be)
return self.tp0, sl, 1.0
GRID = [
{"k": 0.5, "buffer_frac": 0.0},
{"k": 0.5, "buffer_frac": 0.002},
{"k": 1.0, "buffer_frac": 0.0},
{"k": 1.0, "buffer_frac": 0.002},
{"k": 1.5, "buffer_frac": 0.0},
{"k": 1.5, "buffer_frac": 0.002},
]
if __name__ == "__main__":
evaluate(Breakeven, GRID)
@@ -0,0 +1,93 @@
"""EXIT-05 — ratchet: stop a cricchetto sul profitto su close (high-water-mark).
Idea: una volta che il trade ha messo a segno profitto (su CLOSE), proteggiamo
una FRAZIONE f del profitto migliore raggiunto. hwm = miglior close a favore visto
finora; quando hwm e' a favore dell'entrata, lo stop si porta a
long : stop = entry + f*(hwm - entry)
short: stop = entry - f*(entry - hwm)
Lo stop e' SOLO-STRINGENTE (cricchetto): puo' solo avvicinarsi al prezzo, mai
allentarsi. Parte da sl0 (protezione iniziale invariata finche' il ratchet non
supera sl0).
Differenza dal trailing chandelier (EXIT-01): qui lo stop e' ancorato all'ENTRY
+ frazione del profitto su close (non al massimo high - k*ATR). Cattura profitto
in % del guadagno realizzato; con f<1 lascia un cuscinetto sotto l'hwm.
VARIANTI:
- keep_tp=True : il TP fisso tp0 resta (exit standard alla media) + ratchet di
backup sullo SL. horizon = max_bars.
- keep_tp=False: TP RIMOSSO -> ride puro protetto dal cricchetto, horizon=2*mb
(cap HARD_CAP). Qui il ratchet e' l'unica uscita oltre l'orizzonte.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- hwm calcolato sui close[i .. j-1] (mantenuto incrementalmente, aggiornato col
bar j-1 prima di calcolare lo stop attivo nel bar j; close[i]=entry e' incluso
cosi' hwm>=entry sempre, lo stop non scende mai sotto entry una volta armato).
Nessun TP nella variante ride -> nessun fill parziale. after_bar non usato.
GRID: f in {0.3, 0.5, 0.7} x keep_tp in {True, False} (6 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class Ratchet(ExitPolicy):
name = "ratchet"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, f=0.5, keep_tp=True, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.f = float(f)
self.keep_tp = bool(keep_tp)
self.close = ctx["close"]
if not self.keep_tp:
self.horizon = min(2 * mb, HARD_CAP)
# high-water-mark del close a favore sullo slice [i..j-1]; il primo bar
# valutato e' j=i+1 -> slice [i..i] = close[i] = entry (gia' incorporato).
self.hwm = self.close[i] # = entry
self._last_seen = i
self.armed = False # diventa True quando hwm > entry (a favore)
# stop a cricchetto: parte da sl0, puo' solo stringersi
self.cur_stop = sl0
def levels(self, j: int):
c = self.close
d = self.d
# incorpora i close fino a j-1 (causali, gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
cv = c[self._last_seen]
if d == 1:
if cv > self.hwm:
self.hwm = cv
else:
if cv < self.hwm:
self.hwm = cv
tp = self.tp0 if self.keep_tp else None
# profitto su close del miglior bar a favore (>=0 perche' hwm parte da entry)
prof = (self.hwm - self.entry) * d
if prof > 0: # il trade e' andato a favore: arma il ratchet
self.armed = True
if d == 1:
cand = self.entry + self.f * (self.hwm - self.entry)
if cand > self.cur_stop: # cricchetto: lo stop long solo SALE
self.cur_stop = cand
else:
cand = self.entry - self.f * (self.entry - self.hwm)
if cand < self.cur_stop: # cricchetto: lo stop short solo SCENDE
self.cur_stop = cand
return tp, self.cur_stop, 1.0
GRID = [
{"f": f, "keep_tp": keep_tp}
for f in (0.3, 0.5, 0.7)
for keep_tp in (True, False)
]
if __name__ == "__main__":
evaluate(Ratchet, GRID)
@@ -0,0 +1,84 @@
"""EXIT-06 — sar_trail: Parabolic SAR semplificato come trailing stop, TP rimosso.
Idea: come EXIT-01 (cavalcata pura senza TP), ma lo stop trailing non e' un
chandelier a k*ATR fisso bensi' un Parabolic SAR semplificato che ACCELERA: il
fattore af parte a 0.02 e cresce di af_step ad ogni NUOVO estremo favorevole
(cap af_max). Lo stop si avvicina sempre piu' in fretta al prezzo man mano che il
trade corre nella direzione giusta -> protegge i runner stringendo aggressivamente.
sar(j) = sar(j-1) + af * (ep - sar(j-1)) con ep = estremo favorevole (high long / low short)
stop attivo nel bar j = max(sl0, sar) long (min(sl0, sar) short)
af: 0.02 -> +af_step ad ogni nuovo ep -> cap af_max
TP RIMOSSO, horizon = 4*mb (cap HARD_CAP=240).
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1. L'estremo favorevole `ep` e lo
stato SAR vengono aggiornati incorporando i bar fino a j-1 (gia' chiusi al poll
del bar j). sar(j-1) -> sar(j) usa ep aggiornato a j-1. Nessun dato del bar j.
Nessun TP -> nessun fill parziale; after_bar non usato (uscita solo a SL/orizzonte).
GRID: af_max in {0.1, 0.2} x af_step in {0.02, 0.04} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP
class SarTrail(ExitPolicy):
name = "sar_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, af_max=0.2, af_step=0.02, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.af_max = float(af_max)
self.af_step = float(af_step)
self.horizon = min(4 * mb, HARD_CAP)
# estremo favorevole (ep) sullo slice [i..j-1]; inizializzato al bar di entrata
self.ep = ctx["high"][i] if d == 1 else ctx["low"][i]
self.af = 0.02
# SAR iniziale: lo stop di partenza (sl0). Il SAR converge verso ep dall'sl0.
self.sar = sl0
self._last_seen = i # ultimo indice gia' incorporato nello stato SAR
self.cur_stop = sl0 # stop monotono (solo si stringe)
def _step(self, idx):
"""Incorpora il bar `idx` (causale, <= j-1) aggiornando ep, af e sar."""
h = self.ctx["high"][idx]
l = self.ctx["low"][idx]
# avanza il SAR di un passo verso l'estremo favorevole corrente
self.sar = self.sar + self.af * (self.ep - self.sar)
# nuovo estremo favorevole? -> accelera
if self.d == 1:
if h > self.ep:
self.ep = h
self.af = min(self.af + self.af_step, self.af_max)
else:
if l < self.ep:
self.ep = l
self.af = min(self.af + self.af_step, self.af_max)
def levels(self, j):
# incorpora i bar fino a j-1 (gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
self._step(self._last_seen)
if self.d == 1:
if self.sar > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
self.cur_stop = self.sar
else:
if self.sar < self.cur_stop: # lo stop short puo' solo SCENDERE
self.cur_stop = self.sar
return None, self.cur_stop, 1.0 # TP rimosso
GRID = [
{"af_max": am, "af_step": st}
for am in (0.1, 0.2)
for st in (0.02, 0.04)
]
if __name__ == "__main__":
evaluate(SarTrail, GRID)
@@ -0,0 +1,73 @@
"""EXIT-07 — TP dinamico che DECADE col tempo (verso breakeven).
PRIOR (vincolante, ieri SCARTATO un ladder 80/20): il TP delle fade sta alla
MEDIA, e li' il movimento e' esaurito -> il prezzo spesso NON arriva al TP e il
trade muore a max_bars (close) o sullo SL. Idea di questa famiglia: invece di
tenere il TP fisso al target ambizioso (la media), farlo DECADERE col passare
delle barre verso un target piu' modesto (breakeven che copre le fee, o meta'
strada). Cosi' un trade che s'e' avvicinato ma non ha toccato il TP pieno puo'
essere chiuso in profitto/pareggio prima di scadere a max_bars potenzialmente
in perdita. SL FISSO (sl0) invariato. horizon = max_bars invariato.
TP attivo nel bar j:
frac(j) = clamp( ((j - i) / mb) * speed , 0, 1 )
tp(j) = tp0 + frac(j) * (target_fin - tp0)
- frac dipende SOLO da j (indice di tempo) e da costanti note all'entrata
(i, mb, speed) -> NESSUN dato > j-1 -> anti-look-ahead OK per costruzione.
- long (d=+1): tp0 > entry; target_fin <= tp0 -> il TP SCENDE verso entry.
- short (d=-1): tp0 < entry; target_fin >= tp0 -> il TP SALE verso entry.
- speed>1: il TP raggiunge il target finale prima della fine (clamp a 1).
target_fin (param "target"):
- "breakeven": entry*(1 + d*0.003) -> copre 0.3% (~ fee 0.10%RT + margine).
Per long sta sopra entry, per short sotto: chiudere li' NON e' in perdita.
- "halfway" : (entry + tp0)/2 -> meta' strada fra entry e il TP pieno.
GRID: speed in {0.5, 1.0, 1.5} x target in {breakeven, halfway} (6 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class TpDecay(ExitPolicy):
name = "tp_decay"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.speed = float(params.get("speed", 1.0))
target = str(params.get("target", "breakeven"))
if target == "breakeven":
self.target_fin = entry * (1.0 + d * 0.003)
elif target == "halfway":
self.target_fin = 0.5 * (entry + tp0)
else:
raise ValueError(f"target sconosciuto: {target}")
# mb puo' essere 0 in teoria; protezione
self.mb_eff = max(int(mb), 1)
def levels(self, j: int):
# frac dipende SOLO dal tempo (j - i), mb, speed: costanti note all'entrata
frac = ((j - self.i) / self.mb_eff) * self.speed
if frac < 0.0:
frac = 0.0
elif frac > 1.0:
frac = 1.0
tp = self.tp0 + frac * (self.target_fin - self.tp0)
return tp, self.sl0, 1.0
GRID = [
{"speed": 0.5, "target": "breakeven"},
{"speed": 1.0, "target": "breakeven"},
{"speed": 1.5, "target": "breakeven"},
{"speed": 0.5, "target": "halfway"},
{"speed": 1.0, "target": "halfway"},
{"speed": 1.5, "target": "halfway"},
]
if __name__ == "__main__":
evaluate(TpDecay, GRID)
@@ -0,0 +1,77 @@
"""EXIT-08 — SL che si STRINGE linearmente col tempo (time-based stop tighten).
Lo stop iniziale sl0 si avvicina linearmente a un livello d'arrivo `target`
man mano che il trade invecchia. TP fisso (tp0) e horizon=max_bars invariati.
frac(j) = clamp( (j - i) / (mb * stretch), 0, 1 )
sl(j) = sl0 + frac(j) * (target - sl0)
target ("arrivo"):
- "entry" -> entry (a fine corsa lo stop e' a breakeven)
- "midpoint" -> (entry + sl0)/2 (a fine corsa lo stop e' a meta' strada)
Direzioni: il segno e' gestito dalla geometria di sl0 vs entry.
long (d=1): sl0 < entry -> target >= sl0 -> lo stop SALE verso entry
short (d=-1): sl0 > entry -> target <= sl0 -> lo stop SCENDE verso entry
In entrambi i casi lo stop si stringe (avvicina al prezzo d'ingresso) col tempo.
stretch:
- 1.0 : lo stop raggiunge `target` a max_bars (fine horizon).
- 2.0 : lo stop raggiunge `target` a 2*max_bars -> stringe a META' velocita',
quindi a max_bars e' solo a meta' del cammino sl0->target (piu' lasco).
ANTI-LOOK-AHEAD: sl(j) dipende SOLO da {i, j, mb, stretch, entry, sl0}, tutti
noti all'ENTRATA (close[i]). Nessun dato del bar j o successivi entra nel livello
attivo nel bar j. Il TP resta tp0. -> contratto rispettato per costruzione.
MECCANISMO ATTESO: stringere lo stop col tempo taglia prima i trade che ristagnano
(non vanno al TP ne' allo SL iniziale e scadrebbero a max_bars vicino al BE/in
perdita). Rischio: stoppa fuori trade che sarebbero rientrati verso il TP (la fade
e' mean-reversion: il movimento contrario e' spesso transitorio) -> puo' alzare lo
stop-rate effettivo e tagliare winner ritardatari.
GRID: stretch in {1.0, 2.0} x target in {entry, midpoint} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class SlTighten(ExitPolicy):
name = "sl_tighten"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.stretch = float(params.get("stretch", 1.0))
self.target_kind = str(params.get("target", "entry"))
if self.target_kind == "entry":
self.target = entry
elif self.target_kind == "midpoint":
self.target = 0.5 * (entry + sl0)
else:
raise ValueError(f"target sconosciuto: {self.target_kind}")
self.denom = max(self.mb * self.stretch, 1e-9)
def levels(self, j: int):
# frac dipende solo da i, j, mb, stretch -> noti all'entrata. No look-ahead.
frac = (j - self.i) / self.denom
if frac < 0.0:
frac = 0.0
elif frac > 1.0:
frac = 1.0
sl = self.sl0 + frac * (self.target - self.sl0)
return self.tp0, sl, 1.0
GRID = [
{"stretch": 1.0, "target": "entry"},
{"stretch": 1.0, "target": "midpoint"},
{"stretch": 2.0, "target": "entry"},
{"stretch": 2.0, "target": "midpoint"},
]
if __name__ == "__main__":
evaluate(SlTighten, GRID)
@@ -0,0 +1,114 @@
"""EXIT-09 — tp_extend_momentum: cavalca il momentum OLTRE il TP.
Idea: le fade escono al TP fisso (alla media). Ma quando il movimento NON e'
esaurito e sfonda il TP in chiusura, vogliamo provare a cavalcarlo invece di
prendere solo il TP. State machine a 2 fasi:
FASE A (default, TP intatto): tp = tp0, sl = sl0. Exit standard alla media.
Finche' close[j-1] NON ha superato tp0 in chiusura, ci si comporta come base.
FASE B (armata quando close[j-1] supera tp0 a favore):
- TP RIMOSSO (None): non si esce piu' al livello fisso;
- attiva un trail "chandelier" a k*ATR ancorato all'ESTREMO favorevole visto
DAL bar di superamento in poi (high per long, low per short);
- FLOOR a tp0: lo stop trailing non scende mai sotto tp0 (long) / non sale mai
sopra tp0 (short), cosi' NON si esce MAI peggio del TP originale. Lo stop e'
inoltre solo-stringente (cricchetto): puo' solo avvicinarsi al prezzo.
Trigger di arma (su close[j-1], a favore):
long : close[j-1] > tp0 -> arma
short: close[j-1] < tp0 -> arma
Trail attivo (FASE B), con floor a tp0:
long : stop = max( tp0, fav_high_post - k*atr14[j-1] ) (cricchetto: solo sale)
short: stop = min( tp0, fav_low_post + k*atr14[j-1] ) (cricchetto: solo scende)
Orizzonte esteso a 3*mb (cap HARD_CAP=240) per dare spazio al runner post-TP.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- il trigger di arma legge close[j-1] (gia' chiuso al poll del bar j);
- l'estremo favorevole post-superamento e' sullo slice [arm_idx .. j-1]
(mantenuto incrementalmente, incorporando i bar fino a j-1);
- atr14[j-1] (indice causale).
Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato.
GRID: k in {1.0, 2.0, 3.0} (3 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class TpExtendMomentum(ExitPolicy):
name = "tp_extend_momentum"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.0, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(k)
self.horizon = min(3 * mb, HARD_CAP)
self.close = ctx["close"]
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# FASE A finche' armed=False. Si arma quando close[j-1] supera tp0 a favore.
self.armed = False
self.arm_idx = None # bar dal quale si ancora l'estremo favorevole
self.fav_high = None # estremo favorevole post-superamento (long)
self.fav_low = None # estremo favorevole post-superamento (short)
self._last_seen = i # ultimo close gia' esaminato per il trigger
self._fav_seen = None # ultimo bar gia' incorporato nell'estremo
# stop trailing monotono in FASE B: parte dal floor tp0, solo si stringe
self.cur_stop = None
def levels(self, j: int):
c = self.close
d = self.d
# ---- FASE A -> controlla l'arma sui close fino a j-1 (causali) -----------
if not self.armed:
while self._last_seen < j - 1:
self._last_seen += 1
cv = c[self._last_seen]
crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0)
if crossed:
# arma: ancora l'estremo favorevole DAL bar di superamento
self.armed = True
self.arm_idx = self._last_seen
self.fav_high = self.high[self.arm_idx]
self.fav_low = self.low[self.arm_idx]
self._fav_seen = self.arm_idx
self.cur_stop = self.tp0 # floor di partenza = tp0
break
if not self.armed:
return self.tp0, self.sl0, 1.0 # ancora FASE A: TP/SL fissi
# ---- FASE B -> TP rimosso, trail chandelier ancorato post-superamento -----
# incorpora i bar [arm_idx .. j-1] nell'estremo favorevole (dati causali)
while self._fav_seen < j - 1:
self._fav_seen += 1
if self.high[self._fav_seen] > self.fav_high:
self.fav_high = self.high[self._fav_seen]
if self.low[self._fav_seen] < self.fav_low:
self.fav_low = self.low[self._fav_seen]
a = self.atr[j - 1]
if a == a: # non-NaN
if d == 1:
cand = self.fav_high - self.k * a
cand = max(cand, self.tp0) # floor: mai sotto il TP
if cand > self.cur_stop: # cricchetto: solo sale
self.cur_stop = cand
else:
cand = self.fav_low + self.k * a
cand = min(cand, self.tp0) # floor: mai sopra il TP
if cand < self.cur_stop: # cricchetto: solo scende
self.cur_stop = cand
return None, self.cur_stop, 1.0 # TP rimosso in FASE B
GRID = [{"k": k} for k in (1.0, 2.0, 3.0)]
if __name__ == "__main__":
evaluate(TpExtendMomentum, GRID)
@@ -0,0 +1,79 @@
"""EXIT-10 — tp_moving_mean: il TP INSEGUE la media corrente (non quello fisso).
PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' CONGELATO all'entrata
(tp0 = SMA_n(close) al close[i-1]). Ma la media SI MUOVE durante il trade: se il
prezzo ci mette qualche barra a rientrare, la media si e' gia' spostata. Idea:
ridefinire il TP come la SMA_n corrente -> tp(j) = SMA_n(close)[j-1]. Il target
"segue" la media verso cui la fade punta, invece di mirare a una media stantia.
long (d=+1): si compra sotto la media -> tp(j) = SMA_n[j-1] (sopra entry, di norma).
short (d=-1): si vende sopra la media -> tp(j) = SMA_n[j-1] (sotto entry, di norma).
CAP ONESTO (anti exit-in-perdita): se la media si muove CONTRO (long: media scende
sotto entry; short: media sale sopra entry), il TP diventerebbe un'uscita in perdita
mascherata. Lo evitiamo:
long : tp(j) = max(tp(j), entry*(1+0.002))
short: tp(j) = min(tp(j), entry*(1-0.002))
0.002 (~ 0.10% RT fee + margine) garantisce che il tocco del TP sia >= breakeven.
SL FISSO (sl0) invariato; horizon = max_bars invariato.
ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() su close (causale,
ogni valore dipende solo da close <= quel-indice). In levels(j) si legge sma_n[j-1]
-> solo dati <= j-1, mai il bar j. SL e horizon invariati. OK per costruzione.
GRID: n in {20, 50, 100} (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class TpMovingMean(ExitPolicy):
name = "tp_moving_mean"
@classmethod
def prepare(cls, ctx, **params):
n = int(params.get("n", 50))
key = f"sma_{n}"
if key not in ctx:
c = ctx["close"]
ctx[key] = pd.Series(c).rolling(n).mean().values
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
n = int(params.get("n", 50))
self.sma = ctx[f"sma_{n}"]
# cap onesto: il TP non puo' diventare un'uscita in perdita
if d == 1:
self.cap = entry * (1.0 + 0.002)
else:
self.cap = entry * (1.0 - 0.002)
def levels(self, j: int):
# SOLO dati <= j-1
m = self.sma[j - 1]
if not np.isfinite(m):
# warmup della SMA non ancora pronto -> ricadi sul TP fisso d'entrata
return self.tp0, self.sl0, 1.0
if self.d == 1:
tp = max(m, self.cap)
else:
tp = min(m, self.cap)
return tp, self.sl0, 1.0
GRID = [
{"n": 20},
{"n": 50},
{"n": 100},
]
if __name__ == "__main__":
evaluate(TpMovingMean, GRID)
@@ -0,0 +1,93 @@
"""EXIT-11 — z_overshoot: TP OLTRE la media (overshoot di z_off deviazioni std).
PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' la SMA_n al close[i-1].
Ipotesi di questa famiglia: la reversione spesso NON si ferma esattamente alla media,
ma la attraversa (overshoot). Spostando il TP un po' OLTRE la media nella direzione
del trade catturiamo l'overshoot quando c'e', al prezzo di mancare alcuni TP che si
fermano alla media (che poi rientrano o vengono presi da max_bars/SL).
tp(j) = SMA_n[j-1] + d * z_off * STD_n[j-1]
long (d=+1): si compra sotto la media -> TP = media + z_off*std (sopra la media: piu' lontano).
short (d=-1): si vende sopra la media -> TP = media - z_off*std (sotto la media: piu' lontano).
In ENTRAMBI i casi il TP si allontana di z_off*std OLTRE la media nella direzione
del profitto -> target piu' ambizioso. NB: lo z e' un overshoot ADDIZIONALE rispetto
alla media corrente; la media stessa si muove (come EXIT-10) quindi il target insegue
la media + overshoot.
CAP ONESTO (anti exit-in-perdita): se la media + overshoot finisce contro l'entry
(media e' gia' scappata oltre entry contro di noi), il TP non deve diventare un'uscita
in perdita mascherata. Floor a breakeven+fee come EXIT-10:
long : tp(j) = max(tp(j), entry*(1+0.002))
short: tp(j) = min(tp(j), entry*(1-0.002))
SL FISSO (sl0) invariato; horizon = max_bars invariato.
ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() e std_n =
rolling(n).std(ddof=0) su close (causali: ogni valore dipende solo da close <= indice).
In levels(j) si legge sma_n[j-1] e std_n[j-1] -> solo dati <= j-1, mai il bar j.
SL e horizon invariati. OK per costruzione.
GRID: n in {20, 50} x z_off in {0.25, 0.5} (4 celle).
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class ZOvershoot(ExitPolicy):
name = "z_overshoot"
@classmethod
def prepare(cls, ctx, **params):
n = int(params.get("n", 50))
kmean = f"sma_{n}"
kstd = f"std_{n}"
if kmean not in ctx or kstd not in ctx:
c = pd.Series(ctx["close"])
ctx[kmean] = c.rolling(n).mean().values
ctx[kstd] = c.rolling(n).std(ddof=0).values
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
n = int(params.get("n", 50))
self.z_off = float(params.get("z_off", 0.5))
self.sma = ctx[f"sma_{n}"]
self.std = ctx[f"std_{n}"]
# cap onesto: il TP non puo' diventare un'uscita in perdita
if d == 1:
self.cap = entry * (1.0 + 0.002)
else:
self.cap = entry * (1.0 - 0.002)
def levels(self, j: int):
# SOLO dati <= j-1
m = self.sma[j - 1]
s = self.std[j - 1]
if not (np.isfinite(m) and np.isfinite(s)):
# warmup non pronto -> ricadi sul TP fisso d'entrata
return self.tp0, self.sl0, 1.0
tp = m + self.d * self.z_off * s
if self.d == 1:
tp = max(tp, self.cap)
else:
tp = min(tp, self.cap)
return tp, self.sl0, 1.0
GRID = [
{"n": 20, "z_off": 0.25},
{"n": 20, "z_off": 0.5},
{"n": 50, "z_off": 0.25},
{"n": 50, "z_off": 0.5},
]
if __name__ == "__main__":
evaluate(ZOvershoot, GRID)
@@ -0,0 +1,98 @@
"""EXIT-12 — partial_tp_trail: partial AL TP PIENO, poi il runner CORRE col trail.
Idea (diversa dal ladder 80/20 gia' SCARTATO, che metteva uno stop FISSO alla
soglia 80% del TP): qui il partial avviene AL TP PIENO (tp0, alla media), esce una
frazione q del trade, e il RESIDUO resta SENZA TP, protetto da un trailing
chandelier k*ATR ancorato all'estremo favorevole raggiunto DOPO il partial; il
floor dello stop e' tp0 -> il profitto al livello TP e' LOCKATO (il runner non
puo' mai chiudere peggio di tp0). horizon esteso a 3*mb (cap HARD_CAP) per dare
spazio al runner.
Fase 1 (pre-partial): exit standard = (tp0, sl0). Al tocco di tp0 esce q.
Fase 2 (post-partial): TP rimosso. Trail sul residuo:
Long : stop(j) = max( tp0, max(high[part..j-1]) - k*atr14[j-1] ) (solo sale)
Short: stop(j) = min( tp0, min(low [part..j-1]) + k*atr14[j-1] ) (solo scende)
floor = tp0 -> profitto lockato al livello TP, MAI peggio.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- estremo favorevole post-partial sullo slice [part .. j-1] (mantenuto
incrementalmente, aggiornato col bar j-1 prima di calcolare lo stop in j);
- atr14[j-1] (indice causale).
on_partial(j) registra solo l'indice del bar di partial (j) e il prezzo (tp0):
l'estremo della fase 2 parte da high/low del bar di partial j (<= j, ma il primo
bar valutato in fase 2 e' j+1, slice [j..j] noto al poll di j+1 -> causale).
GRID: q in {0.5, 0.7} x k in {2.0, 3.0} (4 celle). tp_frac=q.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class PartialTpTrail(ExitPolicy):
name = "partial_tp_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, q=0.5, k=3.0, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.q = float(q)
self.k = float(k)
self.horizon = min(3 * mb, HARD_CAP)
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# stato fase 2 (post-partial)
self.partial_idx = None # bar in cui e' avvenuto il partial (None = fase 1)
self.fav_high = None # estremo favorevole sullo slice [partial..j-1]
self.fav_low = None
self._last_seen = None # ultimo indice incorporato nell'estremo
self.cur_stop = None # stop trailing fase 2, floor=tp0, monotono
def levels(self, j):
# ---- Fase 1: pre-partial -> exit standard (tp0, sl0), esce frazione q al TP
if self.partial_idx is None:
return self.tp0, self.sl0, self.q
# ---- Fase 2: post-partial -> TP rimosso, trail chandelier floor=tp0
h, l, atr, d = self.high, self.low, self.atr, self.d
# incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
if h[self._last_seen] > self.fav_high:
self.fav_high = h[self._last_seen]
if l[self._last_seen] < self.fav_low:
self.fav_low = l[self._last_seen]
a = atr[j - 1]
if a != a: # NaN -> resta sullo stop corrente
return None, self.cur_stop, 1.0
if d == 1:
cand = self.fav_high - self.k * a
if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
self.cur_stop = cand
else:
cand = self.fav_low + self.k * a
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE
self.cur_stop = cand
return None, self.cur_stop, 1.0
def on_partial(self, j, price, remaining):
# entra in fase 2: ancora l'estremo al bar di partial j (high/low[j] sono
# noti al poll del bar j+1, primo bar valutato in fase 2). floor=tp0.
self.partial_idx = j
self.fav_high = self.high[j]
self.fav_low = self.low[j]
self._last_seen = j
self.cur_stop = self.tp0 # profitto lockato al livello TP, MAI peggio
GRID = [
{"q": q, "k": k}
for q in (0.5, 0.7)
for k in (2.0, 3.0)
]
if __name__ == "__main__":
evaluate(PartialTpTrail, GRID)
@@ -0,0 +1,74 @@
"""EXIT-13 — hurst_exit: TP condizionato al REGIME (rolling-Hurst causale).
IDEA. Le fade puntano alla MEDIA: in regime ANTI-PERSISTENTE (Hurst basso) la
reversione tende a completarsi -> ha senso tenere il TP pieno tp0. In regime
PERSISTENTE/trending (Hurst alto) la reversione spesso NON arriva fino in fondo
(il movimento continua, lo stop-loss si concentra li': vedi loss-guard Hurst) ->
conviene "prendere quel che c'e'": TP anticipato a meta' strada (entry+tp0)/2.
- H[j-1] < h_lo (anti-persistente): tp(j) = tp0 (reversione completa)
- H[j-1] >= h_lo (persistente): tp(j) = (entry+tp0)/2 (TP a meta')
SL FISSO (sl0) e horizon (max_bars) invariati.
ANTI-LOOK-AHEAD. prepare() precalcola H = rolling_hurst(close, window=100, step=6)
una volta sullo sleeve. rolling_hurst e' causale: H[k] usa returns[k-window:k] e
returns[m]=diff(log(close))[m] dipende da close[m+1], quindi H[k] dipende solo da
close <= k. In levels(j) leggo H[j-1] -> solo dati <= j-1. SL/horizon invariati.
La decisione del regime e' fissata col bar precedente, il bar j tocca i livelli.
NB sul TP a meta'. Lo "step=6" significa che H e' costante a tratti di 6 barre; il
regime e' ri-letto ogni bar (j-1) ma cambia valore ogni 6. Il TP a meta' strada
e' SEMPRE >= breakeven per costruzione (e' fra entry e tp0, e tp0 e' gia' oltre il
margine fee per come la fade lo fissa), quindi non maschera uscite in perdita.
GRID: h_lo in {0.45, 0.50, 0.55} (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from src.fractal.indicators import rolling_hurst # noqa: E402
class HurstExit(ExitPolicy):
name = "hurst_exit"
@classmethod
def prepare(cls, ctx, **params):
if "hurst100" not in ctx:
c = ctx["close"]
ctx["hurst100"] = rolling_hurst(c, window=100, step=6)
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.h = ctx["hurst100"]
self.h_lo = float(params.get("h_lo", 0.50))
self.tp_half = (entry + tp0) / 2.0 # TP a meta' strada (persistente)
def levels(self, j: int):
# SOLO dati <= j-1
hv = self.h[j - 1]
if not np.isfinite(hv):
return self.tp0, self.sl0, 1.0
if hv < self.h_lo:
tp = self.tp0 # anti-persistente: reversione completa
else:
tp = self.tp_half # persistente: prendi la meta'
return tp, self.sl0, 1.0
GRID = [
{"h_lo": 0.45},
{"h_lo": 0.50},
{"h_lo": 0.55},
]
if __name__ == "__main__":
evaluate(HurstExit, GRID)
@@ -0,0 +1,73 @@
"""EXIT-14 — protezione GIVEBACK dal picco (trailing sul PROFITTO, non sul prezzo).
Idea: una fade puo' correre a favore quasi fino al TP (alla media) e poi
ritracciare prima di toccarlo, restituendo gran parte del guadagno gia' maturato.
Questa policy traccia l'high-water-mark (hwm) del PROFITTO non realizzato sul
CLOSE; quando il profitto corrente scende sotto (1-g)*hwm_profit -- cioe' si e'
restituita una frazione g del picco -- e il picco era gia' "significativo"
(hwm_profit > soglia * dist(entry, tp0)), esce al close del bar (after_bar).
Il TP fisso tp0 e lo SL fisso sl0 RESTANO invariati e prioritari intrabar.
Razionale: protegge il profitto maturato senza toccare i livelli; non e' un
ladder (ieri scartato perche' al TP/media il movimento e' esaurito) ma un
trailing sul drawdown del trade quando ha gia' fatto buona parte del lavoro.
ANTI-LOOK-AHEAD:
- levels(j) NON e' toccato (TP/SL fissi): nessun dato futuro.
- after_bar(j) decide sul CLOSE del bar j (eseguibile al poll). Aggiorna l'hwm
con il profitto a close[j] e poi confronta: tutto con dati <= j. Il bar j e'
lecito in after_bar per contratto. L'hwm e' un cliquet (non scende mai).
GRID: g in {0.4, 0.6} x arm_frac in {0.3, 0.5} della distanza al TP (4 celle).
- g = frazione del picco di profitto restituita che fa scattare l'uscita.
- arm_frac = il giveback e' armato solo quando hwm_profit ha superato
arm_frac * dist(entry, tp0): finche' il trade non ha "fatto strada"
(frazione del cammino verso la media) non interviene -> non taglia i trade
che partono storti, solo quelli che hanno gia' guadagnato e poi mollano.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class Giveback(ExitPolicy):
name = "giveback"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.g = float(params.get("g", 0.4))
self.arm_frac = float(params.get("arm_frac", 0.3))
self.close = ctx["close"]
# distanza (in prezzo, sempre positiva) dall'entrata al TP iniziale
self.dist_tp = abs(self.tp0 - entry) if self.tp0 is not None else 0.0
self.arm_level = self.arm_frac * self.dist_tp
self.hwm_profit = 0.0 # high-water-mark del profitto a favore (cliquet)
def after_bar(self, j: int) -> bool:
if self.dist_tp <= 0.0:
return False
# profitto NON realizzato a favore, valutato sul close del bar j (lecito qui)
profit = (self.close[j] - self.entry) * self.d
if profit > self.hwm_profit:
self.hwm_profit = profit # aggiorna il picco (non scende mai)
# arma solo se il picco ha superato la soglia (trade gia' "in cammino")
if self.hwm_profit < self.arm_level:
return False
# esci se il profitto corrente ha restituito >= g del picco
if profit < (1.0 - self.g) * self.hwm_profit:
return True
return False
GRID = [
{"g": 0.4, "arm_frac": 0.3},
{"g": 0.4, "arm_frac": 0.5},
{"g": 0.6, "arm_frac": 0.3},
{"g": 0.6, "arm_frac": 0.5},
]
if __name__ == "__main__":
evaluate(Giveback, GRID)
@@ -0,0 +1,61 @@
"""EXIT-15 — loser time-stop ("i loser muoiono giovani?").
Le fade tengono il trade fino a TP/SL fissi o max_bars. Qui aggiungiamo un
solo extra-exit CONDIZIONATO AL SEGNO della PnL: al k-esimo bar dopo l'entrata,
se il trade e' ancora in PERDITA sul CLOSE (unrealized < 0), chiudi il residuo
al close[j]. Se a quel bar il trade e' in profitto (o piatto), NON tocca nulla
e lascia correre verso TP/SL/max_bars come la base.
TP/SL fissi (tp0, sl0) INVARIATI; horizon = max_bars INVARIATO.
Differenza col time-stop semplice (gia' FALLITO il 2026-06-02, tagliava i
winner): qui non si esce per il solo passare del tempo, ma SOLO se la PnL e'
negativa. L'ipotesi e' che un trade ancora in rosso dopo k bar abbia bassa
probabilita' di recuperare e finisca per colpire lo SL (peggio) o scadere a
max_bars in perdita -> uscire prima limita la coda di perdite e libera capitale.
Anti-look-ahead: la decisione e' in `after_bar(j)`, che per contratto puo'
leggere il CLOSE del bar j (eseguibile al poll del tick). Confronto:
unrealized(j) = (close[j] - entry) * d (>0 = a favore)
Esce SOLO quando j - i == k (controllo una tantum al k-esimo bar). I livelli
TP/SL restano quelli base (nessun uso di dati futuri in levels()).
GRID: k in {4, 8, 12, 16} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class LoserTimestop(ExitPolicy):
name = "loser_timestop"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = int(params.get("k", 8))
self.close = ctx["close"]
# livelli base, invariati (nessun look-ahead)
def levels(self, j: int):
return self.tp0, self.sl0, 1.0
def after_bar(self, j: int) -> bool:
# controllo una tantum al k-esimo bar dopo l'entrata
if j - self.i != self.k:
return False
unrealized = (self.close[j] - self.entry) * self.d
return unrealized < 0.0
GRID = [
{"k": 4},
{"k": 8},
{"k": 12},
{"k": 16},
]
if __name__ == "__main__":
evaluate(LoserTimestop, GRID)
@@ -0,0 +1,78 @@
"""EXIT-16 — close_confirm_sl: STOP-LOSS confermato in CHIUSURA (immune ai wick).
IDEA. Lo SL intrabar fisso stoppa anche su un wick transitorio che buca sl0 e
poi rientra. Le fade sono mean-reversion: il movimento contrario e' spesso un
overshoot momentaneo. Qui lo SL NON e' piu' un livello toccato intrabar
(disattivato: sl=None nei livelli, l'engine non lo testa MAI sui low/high) ma
una CONFERMA sul CLOSE del bar: si esce solo se il prezzo CHIUDE oltre sl0.
in levels(j): sl = None (no stop intrabar -> immune ai wick), TP fisso tp0.
in after_bar(j):
long (d=1): esci al close[j] se close[j] < sl0 - buffer*atr14[j]
short (d=-1): esci al close[j] se close[j] > sl0 + buffer*atr14[j]
ONESTO. L'uscita avviene al CLOSE[j], che puo' essere PEGGIO di sl0 quando il
bar gappa o sfonda di slancio (il backtest paga il close reale, non sl0). Questo
e' il costo del "confermare": si rinuncia a fermarsi esatti a sl0 in cambio di
non farsi cacciare dai wick. Il buffer*atr richiede uno sfondamento ulteriore in
chiusura -> meno uscite ma piu' lontane da sl0 quando scattano.
ANTI-LOOK-AHEAD. levels(j) ritorna sl=None e tp0 (nessun dato del bar j). La
decisione di uscita e' in after_bar(j), che PER CONTRATTO puo' leggere il bar j:
close[j] e atr14[j] sono entrambi noti al close del bar j (atr14[j] = rolling
mean di TR fino a j; TR[j] usa high/low/close[j] e close[j-1], tutti chiusi a j).
Nessun dato > j entra nella decisione. -> contratto rispettato.
NB il HARD_CAP/horizon=max_bars resta: se ne' TP ne' close-confirm scattano, il
trade scade al close a max_bars come la base. Quindi un trade puo' restare in
posizione PIU' a lungo della base (la base poteva stopparlo intrabar prima):
l'avg_bars puo' SALIRE -> il ret va pesato per l'esposizione.
GRID: buffer in {0.0, 0.25, 0.5} ATR (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class CloseConfirmSl(ExitPolicy):
name = "close_confirm_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.buffer = float(params.get("buffer", 0.0))
self.close = ctx["close"]
self.atr = ctx["atr14"]
def levels(self, j: int):
# SL intrabar DISATTIVATO (immune ai wick): l'engine non testa low/high
# contro lo stop. TP fisso tp0 intrabar invariato.
return self.tp0, None, 1.0
def after_bar(self, j: int) -> bool:
# Decisione sul CLOSE del bar j -> per contratto j e' leggibile.
a = self.atr[j]
if not np.isfinite(a):
a = 0.0
cj = self.close[j]
if self.d == 1:
# long: stop confermato se chiude SOTTO sl0 - buffer*atr
return cj < self.sl0 - self.buffer * a
else:
# short: stop confermato se chiude SOPRA sl0 + buffer*atr
return cj > self.sl0 + self.buffer * a
GRID = [
{"buffer": 0.0},
{"buffer": 0.25},
{"buffer": 0.5},
]
if __name__ == "__main__":
evaluate(CloseConfirmSl, GRID)
@@ -0,0 +1,86 @@
"""EXIT-17 — wide_sl_trail: RESPIRA POI PROTEGGI.
Idea: l'SL fisso all'entrata (sl0) puo' essere troppo stretto -> stoppa fade che
poi sarebbero rientrate alla media. Qui ALLARGHIAMO l'SL iniziale a
sl_w = entry - w*(entry - sl0) (long, w>1 -> stop piu' lontano = piu' respiro)
sl_w = entry + w*(sl0 - entry) (short)
ma attiviamo SUBITO un trailing chandelier a k*ATR dall'estremo favorevole. Lo stop
attivo nel bar j e' il PIU' PROTETTIVO fra l'SL allargato e il chandelier:
long : sl = max(sl_w, max(high[i..j-1]) - k*ATR[j-1])
short: sl = min(sl_w, min(low[i..j-1]) + k*ATR[j-1])
TP fisso tp0 invariato; horizon = max_bars invariato.
Logica: finche' il prezzo NON corre a favore, il rischio e' l'SL allargato (respiro:
si tollera un ritracciamento iniziale piu' ampio nella speranza che la fade rientri).
Appena il prezzo corre a favore, il chandelier sale sopra sl_w e protegge il
profitto come un trail normale. Differenza da EXIT-02 (che usa max(sl0, chand)):
qui il floor iniziale e' PIU' LARGO di sl0, quindi early il rischio per-trade e'
maggiore (peggiora i loser veloci) ma si salvano fade che con sl0 sarebbero state
stoppate giusto prima del rientro.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1 (estremo favorevole su [i..j-1]
mantenuto incrementalmente fino a j-1; atr14[j-1]). after_bar non usato.
GRID: w in {1.5, 2.0} x k in {2.0, 3.0} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class WideSLTrail(ExitPolicy):
name = "wide_sl_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, w=1.5, k=2.0, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.w = float(w)
self.k = float(k)
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# SL iniziale ALLARGATO: piu' lontano dall'entrata (w>1)
if d == 1:
self.sl_w = entry - self.w * (entry - sl0)
else:
self.sl_w = entry + self.w * (sl0 - entry)
# estremo favorevole running (solo barre <= j-1); init = barra d'entrata i
self.run_hi = self.high[i]
self.run_lo = self.low[i]
self.last_seen = i
def _update_running(self, upto: int) -> None:
"""Incorpora le barre (last_seen, upto] nell'estremo favorevole. upto = j-1
-> NON tocca il bar j (anti-look-ahead)."""
while self.last_seen < upto:
self.last_seen += 1
if self.high[self.last_seen] > self.run_hi:
self.run_hi = self.high[self.last_seen]
if self.low[self.last_seen] < self.run_lo:
self.run_lo = self.low[self.last_seen]
def levels(self, j: int):
self._update_running(j - 1) # solo dati <= j-1
a = self.atr[j - 1]
if a is None or a != a: # NaN nei primi 14 bar -> usa l'SL allargato
return self.tp0, self.sl_w, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl_w, chand) # il piu' protettivo (stop piu' alto)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl_w, chand) # il piu' protettivo (stop piu' basso)
return self.tp0, sl, 1.0
GRID = [
{"w": w, "k": k}
for w in (1.5, 2.0)
for k in (2.0, 3.0)
]
if __name__ == "__main__":
evaluate(WideSLTrail, GRID)
@@ -0,0 +1,108 @@
"""EXIT-18 — SL STRUTTURALE su swing low/high (structural / chandelier-by-structure).
Idea: invece di uno stop FISSO a sl0 (ATR dall'entrata), uno stop ancorato alla
STRUTTURA recente del prezzo. Per un long: il livello "naturale" sotto cui la tesi
mean-reversion e' invalidata e' il minimo dello swing recente; se il prezzo rompe
sotto il minimo degli ultimi n bar la fade ha torto. Simmetrico per lo short sul
massimo recente.
long (d=1): level(j) = min(low[j-n .. j-1]) - buf
short (d=-1): level(j) = max(high[j-n .. j-1]) + buf
buf = 0.25 * atr14[j-1]
SOLO-STRINGENTE: lo stop parte da sl0 (protezione iniziale invariata) e si aggiorna
SOLO se il livello swing e' PIU' PROTETTIVO (cricchetto):
long : cur_stop = max(cur_stop, level) (lo stop puo' solo SALIRE)
short: cur_stop = min(cur_stop, level) (lo stop puo' solo SCENDERE)
Cosi' man mano che il prezzo (per una fade vincente) torna verso la media, lo swing
low/high sale/scende e lo stop segue, bloccando profitto. Non si allenta MAI.
TP fisso (tp0) e horizon=max_bars invariati: questa famiglia cambia SOLO lo stop.
ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1:
- massimi/minimi sullo slice [j-n .. j-1] (lookback chiuso a j-1);
- buffer da atr14[j-1] (indicatore causale, valore del bar precedente).
Mantengo cur_stop in modo incrementale ma lo aggiorno con i bar fino a j-1, mai j.
Nessun dato del bar j o successivi entra nel livello attivo nel bar j.
MECCANISMO ATTESO: lo stop strutturale e' tipicamente PIU' LASCO di sl0 all'inizio
(il minimo recente puo' stare oltre sl0): in quel caso il cricchetto lo IGNORA e
resta a sl0 (non allentiamo mai). Quando invece la struttura si stringe (lo swing
low risale verso il prezzo dopo che la fade comincia a funzionare) lo stop SALE,
proteggendo i guadagni di un trade che poi rintraccia prima del TP.
Prior dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il movimento
e' esaurito -> oltre il TP non c'e' runner. Qui non tocchiamo il TP, quindi non
puntiamo a cavalcare oltre: cerchiamo SOLO di tagliare meglio i loser/ristagni
proteggendo profitto. RISCHIO (fade = mean-reversion): stringere lo stop su un
pullback transitorio stoppa fuori trade che sarebbero rientrati al TP -> winner
tagliati e stop-rate su. Lo misuriamo.
GRID: n in {5, 10, 20} (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class SwingStop(ExitPolicy):
name = "swing_stop"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=10, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.n = int(n)
self.low = ctx["low"]
self.high = ctx["high"]
self.atr14 = ctx["atr14"]
# stop a cricchetto: parte dalla protezione iniziale, puo' solo stringersi
self.cur_stop = sl0
# indice fino a cui ho gia' incorporato i bar nel cricchetto (escluso)
self._last_seen = i
def _swing_level(self, j: int):
"""Livello swing causale usando SOLO low/high su [j-n .. j-1] e atr14[j-1]."""
lo = max(self.i, j - self.n) # non andare prima dell'entrata
hi = j # slice [lo:hi] => indici <= j-1
if hi <= lo:
return None
a = self.atr14[j - 1]
if not np.isfinite(a):
a = 0.0
buf = 0.25 * a
if self.d == 1:
return float(np.min(self.low[lo:hi])) - buf
else:
return float(np.max(self.high[lo:hi])) + buf
def levels(self, j: int):
d = self.d
# aggiorna il cricchetto bar-per-bar fino a j-1 (causale). Per ogni bar k
# passato calcolo il livello swing attivo "a quel momento" e stringo lo stop.
while self._last_seen < j:
self._last_seen += 1
k = self._last_seen
lvl = self._swing_level(k)
if lvl is None:
continue
if d == 1:
if lvl > self.cur_stop: # cricchetto long: lo stop solo SALE
self.cur_stop = lvl
else:
if lvl < self.cur_stop: # cricchetto short: lo stop solo SCENDE
self.cur_stop = lvl
return self.tp0, self.cur_stop, 1.0
GRID = [
{"n": 5},
{"n": 10},
{"n": 20},
]
if __name__ == "__main__":
evaluate(SwingStop, GRID)
@@ -0,0 +1,88 @@
"""EXIT-19 — TP RIMOSSO, exit al CANALE DONCHIAN OPPOSTO (donchian_trail).
Idea: la fade attuale esce al TP (sulla media) + SL fisso (ATR dall'entrata) +
max_bars. Qui TOGLIAMO il TP e usiamo come unica uscita di prezzo uno STOP
DINAMICO ancorato al canale Donchian OPPOSTO alla direzione del trade:
long (d=1): stop(j) = min(low[j-n .. j-1]) (canale inferiore)
short (d=-1): stop(j) = max(high[j-n .. j-1]) (canale superiore)
Per un long che funziona (il prezzo risale verso la media) il canale inferiore
SALE bar dopo bar -> lo stop segue e blocca profitto; usciamo quando il prezzo
ritraccia sotto il minimo recente. Simmetrico short.
FLOOR a sl0 (mai PEGGIO dello SL originale): il livello attivo e' floorato alla
protezione iniziale -> non si allenta mai oltre sl0.
long : stop = max(channel_low, sl0)
short: stop = min(channel_high, sl0)
HORIZON = 4*mb (cap HARD_CAP=240): senza TP la posizione puo' restare a lungo,
quindi diamo molto piu' respiro al time-stop; l'uscita "naturale" e' il canale.
DIFFERENZA da EXIT-18 (swing_stop): qui (a) NON c'e' TP affatto (li' tp0 restava),
(b) niente cricchetto persistente: lo stop e' il canale RICALCOLATO ogni bar (puo'
anche allentarsi rispetto al bar prima, ma mai sotto sl0 grazie al floor),
(c) horizon esteso 4x. E' una uscita puramente trend-following/Donchian innestata
su un ingresso mean-reversion.
ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1:
- min/max sullo slice [j-n .. j-1] (lookback chiuso a j-1, lo[lo:hi] con hi=j);
- nessun dato del bar j entra nel livello attivo nel bar j;
- non si guarda mai high/low[j] per decidere lo stop attivo nel bar j.
PRIOR dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il
movimento e' esaurito; "il runner non corre". Quindi togliendo il TP rischiamo di
restare in posizione MENTRE il prezzo ristagna/rientra, pagando giveback e fee.
Il canale opposto dovrebbe limitare il giveback, ma la mean-reversion fa rientrare
il prezzo prima che il canale si stringa -> probabile uscita PEGGIORE del TP.
Lo misuriamo senza pregiudizio.
GRID: n in {10, 20, 30} (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class DonchianTrail(ExitPolicy):
name = "donchian_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=20, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.n = int(n)
self.low = ctx["low"]
self.high = ctx["high"]
# TP rimosso, horizon esteso 4x (il cap a HARD_CAP lo applica l'engine)
self.horizon = 4 * mb
def levels(self, j: int):
d = self.d
# canale opposto causale: slice [lo : j] => indici <= j-1
lo = max(self.i, j - self.n)
hi = j
if hi <= lo:
# primo bar dopo l'entrata: nessuna finestra -> usa solo sl0 (no TP)
return None, self.sl0, 1.0
if d == 1:
ch = float(np.min(self.low[lo:hi]))
stop = max(ch, self.sl0) # floor: mai sotto sl0
else:
ch = float(np.max(self.high[lo:hi]))
stop = min(ch, self.sl0) # floor: mai sopra sl0
return None, stop, 1.0 # TP = None (rimosso)
GRID = [
{"n": 10},
{"n": 20},
{"n": 30},
]
if __name__ == "__main__":
evaluate(DonchianTrail, GRID)
@@ -0,0 +1,90 @@
"""EXIT-20 — ema_cross_exit: esci quando il close attraversa contro-posizione la EMA_m.
IDEA. Le fade comprano sotto / vendono sopra la media e oggi escono al TP fisso (la
media congelata all'entrata) o a max_bars/SL. Qui usiamo la EMA_m come "linea della
media corrente": finche' il prezzo non l'ha attraversata, la reversione e' ancora in
corso; quando il close la attraversa CONTRO la posizione, la mean-reversion ha
esaurito il suo percorso -> si chiude al close.
long (d=+1): la fade ha comprato sotto la media; esce quando close[j] < ema_m[j]
(il prezzo e' risalito ABOVE la media e ora la ri-perfora al ribasso,
ovvero il close torna sotto la media -> reversione finita/overshoot).
short (d=-1): esce quando close[j] > ema_m[j].
NB sul segno. La condizione "long: close < ema" e' la cross CONTRO la posizione: la
fade long scommette sul rientro VERSO/OLTRE la media; quando il close ricade sotto la
EMA dopo che la reversione e' avvenuta, il segnale di reversione e' consumato. E' un
exit "alla media mobile" che insegue la media invece del target congelato.
VARIANTI keep_tp:
- keep_tp=True : il TP fisso tp0 RESTA attivo (si esce al primo fra TP-al-livello,
SL, ema-cross, max_bars). horizon = max_bars (invariato).
- keep_tp=False: si RIMUOVE il TP fisso (tp=None) e si lascia che sia la ema-cross a
chiudere il vincente; horizon = 2*max_bars (cap HARD_CAP) per dare
spazio alla cross. SL fisso resta SEMPRE.
ANTI-LOOK-AHEAD. prepare() precalcola ema_m = EMA(close, span=m) UNA volta: causale,
ema[k] dipende solo da close <= k. La decisione di uscita e' in after_bar(j), che per
contratto puo' leggere il bar j (close[j], ema_m[j]) ed e' eseguibile al close del
poll. levels(j) usa solo sl0/tp0 (costanti) -> nessun dato > j-1. OK per costruzione.
GRID: m in {5, 10, 20} x keep_tp in {True, False} (6 celle).
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class EmaCrossExit(ExitPolicy):
name = "ema_cross_exit"
@classmethod
def prepare(cls, ctx, **params):
m = int(params.get("m", 10))
key = f"ema_{m}"
if key not in ctx:
c = ctx["close"]
ctx[key] = pd.Series(c).ewm(span=m, adjust=False).mean().values
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
m = int(params.get("m", 10))
self.ema = ctx[f"ema_{m}"]
self.keep_tp = bool(params.get("keep_tp", True))
if not self.keep_tp:
# senza TP la cross deve avere spazio: raddoppia l'orizzonte (cap HARD_CAP)
self.horizon = min(2 * mb, HARD_CAP)
def levels(self, j: int):
# SL fisso sempre; TP fisso solo se keep_tp (altrimenti None -> lo gestisce la cross)
tp = self.tp0 if self.keep_tp else None
return tp, self.sl0, 1.0
def after_bar(self, j: int) -> bool:
# after_bar puo' usare il bar j (close[j], ema[j]) -> eseguibile al close del poll
e = self.ema[j]
if not np.isfinite(e):
return False
c = self.ctx["close"][j]
if self.d == 1:
return c < e # long: close ricade SOTTO la media -> reversione finita
return c > e # short: close risale SOPRA la media
GRID = [
{"m": 5, "keep_tp": True},
{"m": 10, "keep_tp": True},
{"m": 20, "keep_tp": True},
{"m": 5, "keep_tp": False},
{"m": 10, "keep_tp": False},
{"m": 20, "keep_tp": False},
]
if __name__ == "__main__":
evaluate(EmaCrossExit, GRID)
@@ -0,0 +1,71 @@
"""EXIT-21 — vol_rescale (livelli che RESPIRANO con la volatilita').
I TP/SL fissi della base nascono come multipli dell'ATR all'entrata:
m_tp = |tp0 - entry| / atr14[i] (quanti ATR dista il TP)
m_sl = |sl0 - entry| / atr14[i] (quanti ATR dista lo SL)
Invece di congelare la DISTANZA in prezzo (tp0, sl0), congeliamo il MULTIPLO e
lasciamo che la distanza respiri con l'ATR corrente:
tp(j) = entry + d * m_tp * atr14[j-1]
sl(j) = entry - d * m_sl * atr14[j-1]
Cosi' se la vol si comprime mentre la fade torna alla media, il TP si AVVICINA
(prende profitto prima, coerente col fatto che il movimento residuo si esaurisce);
se la vol esplode, lo SL si ALLARGA (meno stop-out su spike), e viceversa.
Anti-look-ahead: m_tp/m_sl usano atr14[i] (noto al close del bar d'entrata, dove
il worker fissa i livelli); levels(j) usa SOLO atr14[j-1]. Se atr14[j-1] e' NaN
(warmup), si ricade sui livelli fissi base (tp0/sl0).
Varianti (mode):
- tp_only : TP respira, SL resta fisso a sl0
- sl_only : SL respira, TP resta fisso a tp0
- both : entrambi respirano
GRID: mode in {tp_only, sl_only, both} (3 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class VolRescale(ExitPolicy):
name = "vol_rescale"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.mode = params.get("mode", "both")
self.atr = ctx["atr14"]
# ATR all'entrata: noto al close[i] (il worker fissa i livelli qui).
a_i = self.atr[i]
if a_i is None or a_i != a_i or a_i <= 0:
# nessun ATR valido all'entrata -> impossibile derivare i multipli:
# la policy degenera nei livelli fissi base.
self.valid = False
self.m_tp = self.m_sl = 0.0
else:
self.valid = True
self.m_tp = abs(tp0 - entry) / a_i
self.m_sl = abs(sl0 - entry) / a_i
def levels(self, j: int):
if not self.valid:
return self.tp0, self.sl0, 1.0
a = self.atr[j - 1] # solo dati <= j-1
if a is None or a != a: # NaN -> ricadi sui fissi base
return self.tp0, self.sl0, 1.0
d = self.d
tp = self.tp0
sl = self.sl0
if self.mode in ("tp_only", "both"):
tp = self.entry + d * self.m_tp * a
if self.mode in ("sl_only", "both"):
sl = self.entry - d * self.m_sl * a
return tp, sl, 1.0
GRID = [{"mode": "tp_only"}, {"mode": "sl_only"}, {"mode": "both"}]
if __name__ == "__main__":
evaluate(VolRescale, GRID)
@@ -0,0 +1,74 @@
"""EXIT-22 — DIAGNOSTICA DEL VALORE DELLO SL sulle fade (MR01/MR02/MR07).
Le fade live escono con TP/SL FISSI decisi all'entrata (al close[i]) + max_bars.
Lo SL e' sl0 = entry -/+ k*ATR (per costruzione della strategia). Questa policy
NON cambia TP ne' horizon: tocca SOLO lo SL per misurare quanto lo SL ATTUALE
aggiunge/toglie a ret/DD/Sharpe per sleeve. Tre regimi:
mode = "none" -> sl = None (SL RIMOSSO: restano TP + max_bars). Il trade
puo' chiudere solo al TP o a scadenza horizon. Esposizione
al rischio di coda massima (nessun taglio della perdita).
mode = "wide2x" -> sl spostato a 2x la distanza entry->sl0 (stop piu' lasco):
sl = entry + 2*(sl0 - entry). Stoppa meno spesso.
mode = "tight05x" -> sl spostato a 0.5x la distanza (stop piu' stretto):
sl = entry + 0.5*(sl0 - entry). Stoppa piu' spesso, ma
perde di meno per stop.
La geometria col segno e' automatica: per long sl0<entry, per short sl0>entry;
scalare (sl0-entry) preserva il lato corretto in entrambi i casi.
ANTI-LOOK-AHEAD: il livello sl(j) dipende SOLO da {entry, sl0, mode}, tutti noti
all'ENTRATA (close[i]). Costante per tutto il trade, identico al baseline tranne
il fattore di scala. Nessun dato del bar j o futuro entra. TP = tp0 invariato.
-> contratto rispettato per costruzione (livello statico, come il baseline).
INTERPRETAZIONE (diagnostica, non per forza deployabile):
- se "none" >= base su ret E DD -> lo SL toglie valore (taglia winner che
sarebbero rientrati: la fade e' mean-reversion, il movimento avverso e'
spesso transitorio).
- se "tight05x" peggiora molto -> lo SL e' gia' sul filo: stringerlo morde i
rientri. se "wide2x" ~ "none" -> lo SL attuale e' quasi inerte (raramente
toccato) e il rischio di coda e' contenuto da max_bars/TP.
- DD e' la metrica chiave: lo SL serve a contenere la coda. Se "none" ha DD
simile o piu' basso, lo SL non sta proteggendo nulla di utile.
GRID: mode in {none, wide2x, tight05x} (3 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
_SCALE = {"none": None, "wide2x": 2.0, "tight05x": 0.5}
class NoSl(ExitPolicy):
name = "no_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.mode = str(params.get("mode", "none"))
if self.mode not in _SCALE:
raise ValueError(f"mode sconosciuto: {self.mode}")
scale = _SCALE[self.mode]
if scale is None:
self.sl = None
else:
# sl = entry + scale*(sl0 - entry): preserva il lato per long/short.
self.sl = entry + scale * (sl0 - entry)
def levels(self, j: int):
return self.tp0, self.sl, 1.0
GRID = [
{"mode": "none"},
{"mode": "wide2x"},
{"mode": "tight05x"},
]
if __name__ == "__main__":
evaluate(NoSl, GRID)
@@ -0,0 +1,99 @@
"""EXIT-23 — sl_tp_ride: LOCK AL TP E CAVALCA.
Idea (variante stretta di EXIT-09). Le fade escono al TP fisso (alla media). Qui
si tiene il TP fisso ATTIVO come exit normale finche' il prezzo non lo SUPERA in
chiusura. Quando close[j-1] supera tp0 a favore:
- TP RIMOSSO (None): non si esce piu' al livello fisso;
- SL spostato esattamente a tp0 (il profitto del TP e' LOCKATO: non si esce mai
peggio del TP originale);
- da tp0 in poi un trail chandelier k*ATR ancorato all'estremo favorevole visto
dal bar di superamento in poi, SOLO-STRINGENTE (cricchetto, mai si allenta).
Differenza dal 09: qui il floor e' ESATTAMENTE tp0 (cosi' come in 09, ma 09 ha
horizon 3*mb fisso e griglia su k 1/2/3); qui il lock scatta subito al primo
superamento in chiusura e la griglia esplora ANCHE l'horizon. State machine:
FASE A (armed=False): tp=tp0, sl=sl0 -> identica a base.
FASE B (armed): tp=None, sl = max(tp0, fav_high - k*atr) (long, cricchetto)
= min(tp0, fav_low + k*atr) (short, cricchetto)
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- trigger di arma su close[j-1] (gia' chiuso al poll del bar j);
- estremo favorevole post-superamento su slice [arm_idx .. j-1] (incrementale);
- atr14[j-1] (indice causale).
Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato.
GRID: k in {1.5, 2.5} x horizon in {2*mb, 4*mb} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class SlTpRide(ExitPolicy):
name = "sl_tp_ride"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.5, hmult=4, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(k)
self.horizon = min(int(hmult) * mb, HARD_CAP)
self.close = ctx["close"]
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
self.armed = False
self.arm_idx = None
self.fav_high = None
self.fav_low = None
self._last_seen = i # ultimo close esaminato per il trigger di arma
self._fav_seen = None # ultimo bar incorporato nell'estremo favorevole
self.cur_stop = None # stop trailing monotono in FASE B (floor tp0)
def levels(self, j: int):
c = self.close
d = self.d
# ---- FASE A: controlla l'arma sui close fino a j-1 (causali) -------------
if not self.armed:
while self._last_seen < j - 1:
self._last_seen += 1
cv = c[self._last_seen]
crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0)
if crossed:
self.armed = True
self.arm_idx = self._last_seen
self.fav_high = self.high[self.arm_idx]
self.fav_low = self.low[self.arm_idx]
self._fav_seen = self.arm_idx
self.cur_stop = self.tp0 # SL spostato a tp0: profitto lockato
break
if not self.armed:
return self.tp0, self.sl0, 1.0 # FASE A: TP/SL fissi
# ---- FASE B: TP rimosso, trail chandelier con floor a tp0 ----------------
while self._fav_seen < j - 1:
self._fav_seen += 1
if self.high[self._fav_seen] > self.fav_high:
self.fav_high = self.high[self._fav_seen]
if self.low[self._fav_seen] < self.fav_low:
self.fav_low = self.low[self._fav_seen]
a = self.atr[j - 1]
if a == a: # non-NaN
if d == 1:
cand = max(self.fav_high - self.k * a, self.tp0)
if cand > self.cur_stop: # cricchetto: solo sale
self.cur_stop = cand
else:
cand = min(self.fav_low + self.k * a, self.tp0)
if cand < self.cur_stop: # cricchetto: solo scende
self.cur_stop = cand
return None, self.cur_stop, 1.0 # TP rimosso in FASE B
GRID = [{"k": k, "hmult": h} for k in (1.5, 2.5) for h in (2, 4)]
if __name__ == "__main__":
evaluate(SlTpRide, GRID)
@@ -0,0 +1,245 @@
"""ADVERSARIAL VERIFY — EXIT-02 trail_atr_keep_tp, LENTE OVERFIT/ROBUSTEZZA.
Tesi del sopravvissuto: lo SL intrabar fisso distrugge valore nelle fade; il
Chandelier trail (k=1.5) + TP fisso migliora Sharpe/DD ovunque (6/6 train, 5/6 OOS).
Ipotesi nulla del verificatore: e' un artefatto. Tre attacchi:
(1) JITTER parametri: k vicini non provati (1.25/1.75) + ponte SL fisso a 3x/4x
ATR (no_sl). Il plateau tiene o e' una cresta?
(2) STABILITA' TEMPORALE: train 2018-20 vs 21-22, OOS 23-11/25-01 vs 25-01/26-05.
Il miglioramento c'e' in OGNI finestra o concentrato in un regime?
(3) DIPENDENZA HURST (decisivo): i segnali in cache hanno hurst_max=0.55 (toglie
il regime trending). Rigenero i segnali SENZA hurst (hurst_max=None) IN MEMORIA
(non tocco la cache) e ripeto base-vs-policy: la tesi "SL dannoso" regge anche
dove gli stop servivano (regime persistente)?
cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_02_overfit.py
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1])) # scripts/analysis
sys.path.insert(0, str(HERE.parents[3])) # project root
import exit_lab # noqa: E402
from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402
CODES, ASSETS, LIVE_PARAMS, _atr14)
from importlib import import_module # noqa: E402
mod = import_module("exit_policies.02_trail_atr_keep_tp")
TrailATRKeepTP = mod.TrailATRKeepTP
from src.data.downloader import load_data # noqa: E402
from src.live.strategy_loader import load_strategy # noqa: E402
SLEEVE_KEYS = [(c, a) for c in CODES for a in ASSETS]
# --------------------------------------------------------------- fixed-SL bridge
class FixedSLmultATR(ExitPolicy):
"""Ponte fra base (SL=sl0) e no_sl: SL fisso a m*ATR(entry) dall'entrata,
TP fisso. Se il trail (k piccolo) batte uno SL fisso GIA' largo (3x/4x),
allora il guadagno e' nel trailing, non solo nell'allontanare lo SL."""
name = "fixed_sl_mult_atr"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
m = float(params.get("m", 3.0))
a = ctx["atr14"][i]
if a is None or a != a:
self.sl = sl0
else:
self.sl = entry - m * a if d == 1 else entry + m * a
def levels(self, j: int):
return self.tp0, self.sl, 1.0
# --------------------------------------------------------------- no-SL bridge
class NoSL(ExitPolicy):
"""Solo TP fisso + horizon, NESSUNO stop. Isola: il valore e' nel TOGLIERE
lo stop (qualsiasi) o nel TRAIL dinamico? Se NoSL ~ trail, il driver e'
'niente SL'; se il trail batte NoSL, il trail aggiunge."""
name = "no_sl"
def levels(self, j: int):
return self.tp0, None, 1.0
def _fmt(r):
if not r:
return " (no trades)"
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>6.2f} "
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
def _summary(rows):
"""rows: list of (sleeve_key, base_dict, pol_dict). Ritorna conteggi miglioramento."""
sh_up = dd_dn = ret_up = n = 0
for _, b, p in rows:
if not b or not p:
continue
n += 1
sh_up += p["sharpe_t"] > b["sharpe_t"]
dd_dn += p["dd_pct"] < b["dd_pct"]
ret_up += p["ret_pct"] > b["ret_pct"]
return sh_up, dd_dn, ret_up, n
# ============================================================= TEST 1: JITTER
def test_jitter(data):
print("\n" + "=" * 78)
print("TEST 1 — JITTER: k vicini (1.25/1.75) + ponte SL fisso 3x/4x ATR + NoSL")
print("=" * 78)
print("\n[1a] Trail k in {1.25, 1.5, 1.75} — plateau o cresta? (OOS)")
for k in (1.25, 1.5, 1.75):
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
p = simulate(TrailATRKeepTP, sl, {"k": k}, start_ms=OOS_START_MS)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" k={k:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
print("\n[1b] SL fisso a m*ATR dall'entrata (m=3,4) — uno stop largo basta? (OOS)")
for m in (3.0, 4.0):
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
p = simulate(FixedSLmultATR, sl, {"m": m}, start_ms=OOS_START_MS)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" m={m:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
print("\n[1c] NoSL (solo TP+horizon) — il driver e' 'togliere lo SL'? (OOS)")
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
p = simulate(NoSL, sl, start_ms=OOS_START_MS)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" NoSL OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
print("\n[1d] dettaglio per sleeve: base vs k=1.5 vs NoSL vs SLx3 (OOS)")
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
t = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=OOS_START_MS)
ns = simulate(NoSL, sl, start_ms=OOS_START_MS)
f3 = simulate(FixedSLmultATR, sl, {"m": 3.0}, start_ms=OOS_START_MS)
tag = f"{key[0].split('_')[0]} {key[1]}"
print(f" {tag:<10} base sh{b.get('sharpe_t',0):>6.2f} | trail1.5 sh{t.get('sharpe_t',0):>6.2f} "
f"| NoSL sh{ns.get('sharpe_t',0):>6.2f} | SLx3 sh{f3.get('sharpe_t',0):>6.2f}")
# ============================================================= TEST 2: TEMPORAL
def test_temporal(data):
print("\n" + "=" * 78)
print("TEST 2 — STABILITA' TEMPORALE (Sharpe base -> trail k=1.5)")
print("=" * 78)
W = [
("train 2018-20", None, int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)),
("train 2021-22", int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6), OOS_START_MS),
("OOS 23-11/25-01", OOS_START_MS, int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6)),
("OOS 25-01/26-05", int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6), None),
]
for label, s, e in W:
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
# mediana del delta-Sharpe
deltas = [p["sharpe_t"] - b["sharpe_t"] for _, b, p in rows if b and p]
med = float(np.median(deltas)) if deltas else 0.0
print(f" {label:<20} Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n} "
f"median dSharpe {med:+.2f}")
# ============================================================= TEST 3: HURST
def _build_sleeves_no_hurst():
"""Rigenera i segnali SENZA il loss-guard Hurst (hurst_max=None), IN MEMORIA.
Replica esattamente load_sleeves() ma con LIVE_PARAMS modificati."""
params = dict(LIVE_PARAMS)
params["hurst_max"] = None
out = {}
for code in CODES:
strat = load_strategy(code)
for asset in ASSETS:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
out[(code, asset)] = {
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
for s in sigs],
"open": df["open"].values.astype(float),
"high": h, "low": l, "close": c,
"ts_ms": df["timestamp"].values.astype(np.int64),
"atr14": _atr14(h, l, c),
}
return out
def test_hurst(data):
print("\n" + "=" * 78)
print("TEST 3 — DIPENDENZA DAL FILTRO HURST (decisivo)")
print("=" * 78)
print("Rigenero segnali con hurst_max=None (loss-guard OFF -> include il regime")
print("trending/persistente dove gli stop dovrebbero servire). Confronto base->trail.")
nh = _build_sleeves_no_hurst()
# quanti segnali in piu' (il guard ne toglieva)
print("\n segnali: con-guard -> senza-guard")
for key in SLEEVE_KEYS:
ng = len(data[key]["signals"])
nn = len(nh[key]["signals"])
tag = f"{key[0].split('_')[0]} {key[1]}"
print(f" {tag:<10} {ng:>4} -> {nn:>4} (+{nn-ng})")
for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
print(f"\n [{scope}] base vs trail k=1.5 — SENZA hurst guard")
rows = []
for key in SLEEVE_KEYS:
sl = nh[key]
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
rows.append((key, b, p))
tag = f"{key[0].split('_')[0]} {key[1]}"
print(f" {tag:<10} base {_fmt(b)}")
print(f" {'':<10} trail{_fmt(p)}")
sh, dd, ret, n = _summary(rows)
print(f" --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
# contro-prova: con-guard sugli STESSI scope, per isolare l'effetto guard
print("\n [CONTROLLO] stesso confronto CON hurst guard (cache):")
for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" [{scope}] con-guard --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
if __name__ == "__main__":
data = load_sleeves()
test_jitter(data)
test_temporal(data)
test_hurst(data)
print("\nDONE")
@@ -0,0 +1,329 @@
"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' su EXIT-02 (k=1.5).
Ipotesi nulla avversaria: l'edge del chandelier trail e' un artefatto del
TIMING PERFETTO (livelli fissati a j-1 e tocco a j senza alcun attrito) e/o di
uno SL fillato a un prezzo non eseguibile live. Provo a confutarla.
Esperimenti:
(A) AUDIT del contratto: ricalcolo i livelli di EXIT-02 fuori dall'engine e
verifico che run_hi/run_lo a j NON incorporino mai high[j]/low[j], e che
atr usato sia atr[j-1]. (statico, ma lo confermo numericamente forzando
un confronto con una variante che USA j -> deve cambiare i numeri.)
(B) LAG +1: variante che ritarda di UN bar in piu' TUTTI gli input causali
(atr[j-2], estremi fino a j-2). Se l'edge collassa -> appeso al timing.
(C) ESEGUIBILITA' SL: lo SL fillato a `sl` (prezzo del livello) e' ottimistico?
Confronto col fill conservativo allo SL ma con slippage, e con fill al
WORSE fra sl e open[j] (gap-through). Stima costo.
(D) ESEGUIBILITA' HORIZON/CLOSE: gli exit a max_bars escono a close[j]. Live
il poll arriva ~al close ma esegue al bar dopo -> rifaccio l'engine con
gli exit a horizon/after_bar a open[j+1] invece di close[j]. Costo?
(EXIT-02 non usa after_bar, ma usa gli exit a horizon -> rilevante con
avg_bars 2.5: il turnover alto AMPLIFICA il costo per-exit.)
Tutto a leva 3, fee 0.10% RT, OOS_START 2023-11-01.
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab as EL # noqa: E402
from exit_lab import ExitPolicy, load_sleeves, simulate, OOS_START_MS, LEV, POS, FEE_RT # noqa: E402
from importlib import import_module # noqa: E402
mod = import_module("02_trail_atr_keep_tp")
TrailATRKeepTP = mod.TrailATRKeepTP
KPICK = 1.5
DATA = load_sleeves()
# ---------------------------------------------------------------- (A) AUDIT statico
class TrailLeak(TrailATRKeepTP):
"""VARIANTE SPORCA: usa run_hi/run_lo fino a j (incl. bar j) e atr[j].
Serve SOLO a mostrare quanto l'edge gonfierebbe col look-ahead -> se i numeri
della policy pulita fossero gia' a quel livello, sarebbe sospetta."""
name = "trail_LEAK"
def levels(self, j: int):
self._update_running(j) # SPORCO: incorpora bar j
a = self.atr[j] # SPORCO: atr del bar j
if a is None or a != a:
return self.tp0, self.sl0, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl0, chand)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl0, chand)
return self.tp0, sl, 1.0
# ---------------------------------------------------------------- (B) LAG +1
class TrailLag1(TrailATRKeepTP):
"""Ritarda di UN bar in piu': estremi fino a j-2, atr[j-2]."""
name = "trail_LAG1"
def levels(self, j: int):
self._update_running(j - 2) # estremi solo <= j-2
idx = j - 2
a = self.atr[idx] if idx >= 0 else None
if a is None or a != a:
return self.tp0, self.sl0, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl0, chand)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl0, chand)
return self.tp0, sl, 1.0
# ------------------------------------- (C)/(D) engine alternativo con attriti exec
def simulate_exec(policy_cls, sleeve, params, *, start_ms=None, end_ms=None,
sl_slip_bps=0.0, sl_gap=False, horizon_open=False):
"""Clone di EL.simulate con attriti di esecuzione opzionali:
sl_slip_bps : slippage avverso (bps di prezzo) sul fill allo SL.
sl_gap : fill allo SL = worse(sl, open[j]) (gap-through realistico).
horizon_open: exit a horizon/after_bar a open[j+1] invece di close[j].
"""
params = params or {}
o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = FEE_RT * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
bars_tot = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), EL.HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
fills.append((remaining, px)); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fill_px = sl
if sl_gap:
# gap-through: se la barra apre gia' oltre lo SL, fill all'open
fill_px = min(sl, o[j]) if d == 1 else max(sl, o[j])
if sl_slip_bps:
fill_px = fill_px * (1 - d * sl_slip_bps / 1e4) # avverso
fills.append((remaining, fill_px)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
fills.append((remaining, px)); remaining = 0.0
break
if step == horizon:
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
fills.append((remaining, px)); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {
"ret_pct": (capital / 1000.0 - 1) * 100,
"dd_pct": max_dd * 100,
"trades": trades,
"win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades,
}
def oos(cls, sleeve, sim=simulate, **kw):
return sim(cls, sleeve, {"k": KPICK}, start_ms=OOS_START_MS, **kw)
def line(tag, r):
return (f"{tag:<26} ret{r.get('ret_pct',0):>7.0f}% dd{r.get('dd_pct',0):>5.1f} "
f"sh{r.get('sharpe_t',0):>5.2f} n{r.get('trades',0):>4} "
f"win{r.get('win_pct',0):>4.0f}% bars{r.get('avg_bars',0):>4.1f}")
print("=" * 90)
print("OOS (2023-11-01+) k=1.5 — clean / LEAK(look-ahead) / LAG1(+1 bar) per sleeve")
print("=" * 90)
agg = {"clean": [], "leak": [], "lag1": []}
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
rc = oos(TrailATRKeepTP, sleeve)
rk = oos(TrailLeak, sleeve)
rl = oos(TrailLag1, sleeve)
agg["clean"].append(rc.get("ret_pct", 0))
agg["leak"].append(rk.get("ret_pct", 0))
agg["lag1"].append(rl.get("ret_pct", 0))
print(f"\n{key}")
print(" " + line("clean", rc))
print(" " + line("LEAK", rk))
print(" " + line("LAG1", rl))
print("\n" + "-" * 90)
print(f"OOS ret medio clean={np.mean(agg['clean']):.0f}% "
f"LEAK={np.mean(agg['leak']):.0f}% LAG1={np.mean(agg['lag1']):.0f}%")
print(f"LAG1/clean ratio per sleeve: "
f"{[f'{a/ b:.2f}' if b else 'na' for a, b in zip(agg['lag1'], agg['clean'])]}")
print("\n" + "=" * 90)
print("ESECUZIONE — attriti su OOS k=1.5 (medie sui 6 sleeve)")
print("=" * 90)
scenarios = {
"clean (engine std)": dict(),
"SL slip 5bps": dict(sl_slip_bps=5.0),
"SL slip 10bps": dict(sl_slip_bps=10.0),
"SL gap-through(open)": dict(sl_gap=True),
"SL gap + slip 5bps": dict(sl_gap=True, sl_slip_bps=5.0),
"horizon exit @open[j+1]": dict(horizon_open=True),
"horizon@open + SLslip5": dict(horizon_open=True, sl_slip_bps=5.0),
}
for tag, kw in scenarios.items():
rets, dds, shs = [], [], []
for (code, asset), sleeve in DATA.items():
r = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, **kw)
rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0)); shs.append(r.get("sharpe_t", 0))
print(f"{tag:<28} OOS ret medio {np.mean(rets):>6.0f}% dd {np.mean(dds):>4.1f} "
f"sh {np.mean(shs):>4.2f} (min ret {min(rets):>5.0f}%)")
# confronto baseline per contesto
print("\nBaseline (exit fissa) OOS ret medio per riferimento:")
brets = [simulate(ExitPolicy, s, {}, start_ms=OOS_START_MS).get("ret_pct", 0)
for s in DATA.values()]
print(f" base OOS ret medio {np.mean(brets):.0f}%")
# ---------------------------------------------------------------- (E) frequenza gap-through SL
print("\n" + "=" * 90)
print("FREQUENZA gap-through allo SL (OOS k=1.5): quanti fill SL aprono OLTRE il livello?")
print("=" * 90)
def gap_stats(sleeve, start_ms=OOS_START_MS):
params = {"k": KPICK}
o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve); TrailATRKeepTP.prepare(ctx, **params)
last_exit = -1
sl_hits = gaps = 0
gap_bps = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms or i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = TrailATRKeepTP(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), EL.HARD_CAP)
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
break
tp, sl, _ = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
sl_hits += 1
# gap-through: l'open e' gia' oltre lo SL (peggio del livello)?
worse = (d == 1 and o[j] < sl) or (d == -1 and o[j] > sl)
if worse:
gaps += 1
gap_bps.append(abs(o[j] - sl) / sl * 1e4)
break
if hit_tp:
break
last_exit = j
return sl_hits, gaps, (np.mean(gap_bps) if gap_bps else 0.0), (np.median(gap_bps) if gap_bps else 0.0)
tot_h = tot_g = 0
for (code, asset), sleeve in DATA.items():
sh_, g_, m_, md_ = gap_stats(sleeve)
tot_h += sh_; tot_g += g_
print(f" {code.split('_')[0]} {asset}: SL hits {sh_:>3} gap-through {g_:>3} "
f"({100*g_/max(sh_,1):>4.0f}%) gap medio {m_:>5.1f}bps mediano {md_:>5.1f}bps")
print(f"\n TOTALE: {tot_g}/{tot_h} SL hit sono gap-through = {100*tot_g/max(tot_h,1):.0f}%")
# ------------------------------------------------- (F) gap pessimista vs baseline per sleeve
print("\n" + "=" * 90)
print("VERDETTO ESEGUIBILITA': EXIT-02 con SL gap-through vs BASELINE (exit fissa), OOS, per sleeve")
print("=" * 90)
print(f"{'sleeve':<10}{'base ret':>10}{'base dd':>9}{'base sh':>9} "
f"{'trail gap ret':>14}{'gap dd':>8}{'gap sh':>8} verdetto")
n_better = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, {}, start_ms=OOS_START_MS)
g = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
better = g.get("sharpe_t", 0) >= b.get("sharpe_t", 0)
dd_better = g.get("dd_pct", 99) <= b.get("dd_pct", 99)
n_better += better
verdict = ("Sharpe+DD>" if better and dd_better else
"DD> ret<" if dd_better and not better else "PEGGIO")
print(f"{key:<10}{b.get('ret_pct',0):>9.0f}%{b.get('dd_pct',0):>8.1f}{b.get('sharpe_t',0):>9.2f} "
f"{g.get('ret_pct',0):>13.0f}%{g.get('dd_pct',0):>7.1f}{g.get('sharpe_t',0):>8.2f} {verdict}")
print(f"\n sleeve con Sharpe trail-gap >= baseline: {n_better}/6")
# ----------------------- (G) CONFRONTO EQUO: baseline E trail entrambi con gap-through SL
print("\n" + "=" * 90)
print("CONFRONTO EQUO (gap-through SL su ENTRAMBI) — la tesi 'SL fisso distrugge valore' regge?")
print("=" * 90)
print(f"{'sleeve':<10}{'BASE-gap ret':>13}{'dd':>6}{'sh':>6} {'TRAIL-gap ret':>14}{'dd':>6}{'sh':>6} verdetto")
trail_wins = 0
agg_b = []; agg_t = []
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = oos(ExitPolicy, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
t = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
agg_b.append(b.get("sharpe_t", 0)); agg_t.append(t.get("sharpe_t", 0))
win = t.get("sharpe_t", 0) >= b.get("sharpe_t", 0)
trail_wins += win
print(f"{key:<10}{b.get('ret_pct',0):>12.0f}%{b.get('dd_pct',0):>6.1f}{b.get('sharpe_t',0):>6.2f} "
f"{t.get('ret_pct',0):>13.0f}%{t.get('dd_pct',0):>6.1f}{t.get('sharpe_t',0):>6.2f} "
f"{'TRAIL>=' if win else 'BASE>'}")
print(f"\n con ENTRAMBI gap-through: trail Sharpe >= baseline su {trail_wins}/6 sleeve")
print(f" Sharpe medio BASE-gap={np.mean(agg_b):.2f} TRAIL-gap={np.mean(agg_t):.2f}")
@@ -0,0 +1,217 @@
"""STRESS verifier for EXIT-02 trail_atr_keep_tp (train-pick k=1.5).
Adversarial lens = STRESS. Hypothesis to try to REFUTE the survivor:
(1) Fee 2x (FEE_RT=0.002): the policy raises turnover (avg_bars 9->2.5) =>
it should be disproportionately hurt by doubling fees.
(2) Bear/crash subperiod 2021-01..2022-12 (2021-05-19 crash, LUNA, FTX):
does the DD/tail of the policy survive? compare worst trade + 5 worst.
(3) Adverse slippage on the POLICY exits (+20bps against position on exit
price): the trail exits more often near wicks -> does edge survive?
(4) Turnover / capital-churn: the policy turns capital ~3.6x faster. Quantify
how many distinct trades each takes and the compounding effect.
We monkeypatch exit_lab.FEE_RT and re-implement a thin per-trade collector that
mirrors exit_lab.simulate EXACTLY (same SL-before-TP, same fills, same compounding)
so we can extract per-trade rets/exit prices for tail analysis and slippage.
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, load_sleeves, OOS_START_MS, HARD_CAP # noqa: E402
# import the survivor policy
import importlib.util
spec = importlib.util.spec_from_file_location(
"p02", str(Path(__file__).resolve().parent / "02_trail_atr_keep_tp.py"))
p02 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p02)
TrailATRKeepTP = p02.TrailATRKeepTP
import pandas as pd
BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)
BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6)
def simulate_detailed(policy_cls, sleeve, params=None, start_ms=None, end_ms=None,
exit_slip_bps=0.0):
"""Mirror of exit_lab.simulate but returns per-trade detail and supports an
adverse slippage applied to every fill price (against the position)."""
params = params or {}
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * exit_lab.LEV
LEV, POS = exit_lab.LEV, exit_lab.POS
slip = exit_slip_bps * 1e-4
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
rets = []
tdetail = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
# adverse slippage: every exit fill price moves AGAINST the position
# long (d=1) sells lower -> p*(1-slip); short (d=-1) buys back higher -> p*(1+slip)
adj_fills = [(f, p * (1.0 - d * slip)) for f, p in fills]
ret = sum(f * (p - entry) for f, p in adj_fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
rets.append(ret)
tdetail.append({"i": i, "j": j, "d": d, "ret": ret, "bars": j - i, "ts": int(ts[i])})
if not rets:
return {}
r = np.array(rets)
return {
"ret_pct": (capital / 1000.0 - 1) * 100,
"dd_pct": max_dd * 100,
"trades": len(r),
"win_pct": (r > 0).mean() * 100,
"avg_ret_bps": r.mean() * 1e4,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": np.mean([t["bars"] for t in tdetail]),
"detail": tdetail,
}
def run_grid(data, fee_rt, slip_bps, start_ms, end_ms, label):
orig = exit_lab.FEE_RT
exit_lab.FEE_RT = fee_rt
print(f"\n===== {label} (FEE_RT={fee_rt}, slip={slip_bps}bps, "
f"start={start_ms}, end={end_ms}) =====")
agg = {}
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = simulate_detailed(ExitPolicy, sleeve, {}, start_ms, end_ms, slip_bps)
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, start_ms, end_ms, slip_bps)
if not base or not pol:
continue
agg[key] = (base, pol)
print(f"{key:<10} BASE ret{base['ret_pct']:>8.0f}% dd{base['dd_pct']:>5.1f} "
f"sh{base['sharpe_t']:>5.2f} n{base['trades']:>4} bars{base['avg_bars']:>4.1f} "
f"| POL ret{pol['ret_pct']:>8.0f}% dd{pol['dd_pct']:>5.1f} "
f"sh{pol['sharpe_t']:>5.2f} n{pol['trades']:>4} bars{pol['avg_bars']:>4.1f}")
exit_lab.FEE_RT = orig
return agg
def policy_better(agg, metric="sharpe_t"):
"""count sleeves where policy >= base on metric (and ret not collapsed)."""
n_better_sh = n_better_dd = n_ret_ok = 0
for key, (base, pol) in agg.items():
if pol["sharpe_t"] >= base["sharpe_t"]:
n_better_sh += 1
if pol["dd_pct"] <= base["dd_pct"]:
n_better_dd += 1
if pol["ret_pct"] >= base["ret_pct"] * 0.5 or pol["ret_pct"] >= base["ret_pct"]:
n_ret_ok += 1
return n_better_sh, n_better_dd, n_ret_ok, len(agg)
def main():
data = load_sleeves()
# ---- LENS 1: fee 2x on full OOS
a_base_oos = run_grid(data, 0.001, 0.0, OOS_START_MS, None, "L0 OOS baseline fee (sanity)")
a_fee2_oos = run_grid(data, 0.002, 0.0, OOS_START_MS, None, "L1 OOS FEE 2x")
# ---- LENS 3: adverse slippage 20bps on exits (OOS, normal fee)
a_slip_oos = run_grid(data, 0.001, 20.0, OOS_START_MS, None, "L3 OOS +20bps adverse exit slippage")
# ---- LENS 1+3 combined (worst case): fee 2x AND slippage
a_both = run_grid(data, 0.002, 20.0, OOS_START_MS, None, "L1+3 OOS fee2x + 20bps slip")
# ---- LENS 2: bear/crash subperiod 2021-2022
a_bear = run_grid(data, 0.001, 0.0, BEAR_START, BEAR_END, "L2 BEAR 2021-2022")
# ---- LENS 2 tail: worst trade + 5 worst, base vs policy, on bear window
print("\n===== L2 TAIL: worst trades in 2021-2022 (base vs policy) =====")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = simulate_detailed(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END)
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, BEAR_START, BEAR_END)
if not base or not pol:
continue
bw = sorted([t["ret"] for t in base["detail"]])[:5]
pw = sorted([t["ret"] for t in pol["detail"]])[:5]
print(f"{key:<10} base 5-worst(bps) {[f'{x*1e4:.0f}' for x in bw]} "
f"| pol 5-worst(bps) {[f'{x*1e4:.0f}' for x in pw]}")
print(f"{'':<10} base worst {bw[0]*1e4:.0f}bps pol worst {pw[0]*1e4:.0f}bps "
f"base n={base['trades']} pol n={pol['trades']}")
# ---- LENS 4: turnover / capital churn quantification (OOS)
print("\n===== L4 TURNOVER / capital churn (OOS) =====")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = simulate_detailed(ExitPolicy, sleeve, {}, OOS_START_MS, None)
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, OOS_START_MS, None)
if not base or not pol:
continue
# bars in market total, fraction of time deployed
n = len(sleeve["close"])
base_bars = sum(t["bars"] for t in base["detail"])
pol_bars = sum(t["bars"] for t in pol["detail"])
churn = (pol["trades"] / max(base["trades"], 1))
print(f"{key:<10} trades base{base['trades']:>4} pol{pol['trades']:>4} "
f"(x{churn:.2f}) | bars-in-mkt base{base_bars:>5} pol{pol_bars:>5} "
f"| avg_bars base{base['avg_bars']:.1f} pol{pol['avg_bars']:.1f} "
f"| win% base{base['win_pct']:.0f} pol{pol['win_pct']:.0f}")
# ---- VERDICT helpers
print("\n===== VERDICT TALLIES =====")
for lbl, agg in [("OOS baseline-fee", a_base_oos), ("OOS fee2x", a_fee2_oos),
("OOS slip20", a_slip_oos), ("OOS fee2x+slip", a_both),
("BEAR 2021-22", a_bear)]:
nsh, ndd, nret, tot = policy_better(agg)
print(f"{lbl:<20} policy>=base: sharpe {nsh}/{tot} dd-better {ndd}/{tot} "
f"ret>=50%base {nret}/{tot}")
if __name__ == "__main__":
main()
@@ -0,0 +1,211 @@
"""Verifica avversariale LEAKAGE/ESEGUIBILITA' per EXIT-16 close_confirm_sl.
Tre attacchi:
A) CONTRATTO: dump statico di cosa legge la policy (close[j], atr[j]) e prova
che nessun indice > j entra nella decisione. Replica esatta del numero
headline (MR02 BTC/ETH OOS) per ancorare.
B) LAG: variante con UN bar di ritardo in piu' sugli input causali della
soglia (atr14[j-1] e confronto su close[j-1] invece di close[j]). Se l'edge
collassa -> appeso al timing perfetto. La decisione resta eseguibile
(close[j-1] noto a j-1), ma sposta il momento dello stop di un bar.
C) ESEGUIBILITA' LIVE: il worker esce al POLL successivo, non al close[j]
esatto. Stima del costo eseguendo l'uscita a open[j+1] invece di close[j].
Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_16_leakage.py
"""
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # scripts/analysis
sys.path.insert(0, str(HERE.parents[2])) # project root
import exit_lab # noqa: E402
from exit_lab import (ExitPolicy, load_sleeves, simulate, OOS_START_MS) # noqa: E402
# import the survivor policy directly from its file
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location("p16", HERE / "16_close_confirm_sl.py")
p16 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p16)
CloseConfirmSl = p16.CloseConfirmSl
BUF = 0.5 # train-pick buffer
# --------------------------------------------------------------- B) LAG variant
class CloseConfirmSlLag(ExitPolicy):
"""Identica a EXIT-16 ma con 1 bar di ritardo sugli input della soglia:
decisione su close[j-1] e atr[j-1] (eseguibile gia' a j-1). Se l'edge
dipendeva dal close[j] esatto del bar di sfondamento, qui collassa."""
name = "close_confirm_sl_lag"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.buffer = float(params.get("buffer", 0.0))
self.close = ctx["close"]
self.atr = ctx["atr14"]
def levels(self, j):
return self.tp0, None, 1.0
def after_bar(self, j):
jj = j - 1
if jj <= self.i:
return False
a = self.atr[jj]
if not np.isfinite(a):
a = 0.0
cj = self.close[jj]
if self.d == 1:
return cj < self.sl0 - self.buffer * a
return cj > self.sl0 + self.buffer * a
# ----------------------------------------- C) execution-delay (open[j+1]) variant
def simulate_open_next(sleeve, params, start_ms=None, end_ms=None):
"""Come exit_lab.simulate ma quando la policy esce sul CLOSE (after_bar o
horizon) il FILL avviene a open[j+1] (poll successivo), non a close[j].
I TP/SL intrabar restano al livello (limit). Stima il costo del ritardo
di un poll per un'exit market al prossimo bar."""
h = sleeve["high"]; l = sleeve["low"]; c = sleeve["close"]
o = sleeve["open"]; ts = sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
CloseConfirmSl.prepare(ctx, **params)
fee = exit_lab.FEE_RT * exit_lab.LEV
POS = exit_lab.POS; LEV = exit_lab.LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
bars_tot = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = CloseConfirmSl(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), exit_lab.HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
# EXECUTION DELAY: fill al prossimo open invece di close[j]
px = o[j + 1] if j + 1 < n else c[j]
fills.append((remaining, px)); remaining = 0.0
break
if step == horizon:
px = o[j + 1] if j + 1 < n else c[j]
fills.append((remaining, px)); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
"trades": trades, "win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades}
def fmt(r):
if not r:
return "(no trades)"
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
def main():
data = load_sleeves()
params = {"buffer": BUF}
keys = list(data.keys())
# ---------------------------------------- A) contratto / ancoraggio headline
print("=" * 96)
print("A) ANCORAGGIO (OOS) base vs EXIT-16(buf=0.5) vs LAG(+1 bar) vs OPEN[j+1] delay")
print("=" * 96)
survive_base = survive_lag = survive_delay = 0
agg = {}
for key in keys:
sl = data[key]
b_oos = simulate(ExitPolicy, sl, {}, start_ms=OOS_START_MS)
s_oos = simulate(CloseConfirmSl, sl, params, start_ms=OOS_START_MS)
lag_oos = simulate(CloseConfirmSlLag, sl, params, start_ms=OOS_START_MS)
del_oos = simulate_open_next(sl, params, start_ms=OOS_START_MS)
name = f"{key[0].split('_')[0]} {key[1]}"
print(f"\n{name}")
print(f" base {fmt(b_oos)}")
print(f" EXIT16 {fmt(s_oos)}")
print(f" LAG+1 {fmt(lag_oos)}")
print(f" DELAY {fmt(del_oos)}")
# survivorship: EXIT16 sharpe >= base sharpe?
if s_oos and b_oos and s_oos["sharpe_t"] >= b_oos["sharpe_t"]:
survive_base += 1
if lag_oos and b_oos and lag_oos["sharpe_t"] >= b_oos["sharpe_t"]:
survive_lag += 1
if del_oos and b_oos and del_oos["sharpe_t"] >= b_oos["sharpe_t"]:
survive_delay += 1
agg[name] = dict(base=b_oos, exit16=s_oos, lag=lag_oos, delay=del_oos)
print("\n" + "=" * 96)
print(f"GATE OOS (sharpe >= base): EXIT16 {survive_base}/6 | LAG+1 {survive_lag}/6 "
f"| DELAY(open[j+1]) {survive_delay}/6")
# ---------------------------------------- quantify lag/delay damage on headline
print("\nDanno relativo su sharpe OOS (EXIT16 = 100%):")
for name, a in agg.items():
s = a["exit16"]["sharpe_t"] if a["exit16"] else 0
lg = a["lag"]["sharpe_t"] if a["lag"] else 0
dl = a["delay"]["sharpe_t"] if a["delay"] else 0
ls = f"{100*lg/s:5.0f}%" if s else " n/a"
ds = f"{100*dl/s:5.0f}%" if s else " n/a"
print(f" {name:<10} sh{s:5.2f} LAG->{ls} DELAY->{ds}")
# ---------------------------------------- B) per-trade audit of decision indices
print("\n" + "=" * 96)
print("B) AUDIT INDICI: la decisione after_bar(j) legge close[j], atr[j]. "
"Verifico\n che simulate() chiami after_bar SOLO con j = i+step (mai > j corrente).")
# static guarantee from code; demonstrate atr[j] is causal (rolling mean to j)
sl = data[keys[0]]
print(f" atr14[k] = rolling(14).mean(TR) -> usa TR[k-13..k], tutti chiusi a k. OK")
print(f" close[j] noto al close del bar j. Nessun indice > j nella decisione. OK")
if __name__ == "__main__":
main()
@@ -0,0 +1,198 @@
"""VERIFY EXIT-16 close_confirm_sl — lente OVERFIT/ROBUSTEZZA (avversariale).
Ipotesi nulla: il risultato e' un artefatto (overfit di cella / di regime / di
dipendenza dal loss-guard Hurst gia' applicato in cache). Tre test:
(1) JITTER PARAMETRI: buffer fuori griglia {0.4, 0.6, 0.75, 1.0} + ponte verso la
base con SL fisso a 3x/4x ATR (no_sl come limite). Il plateau tiene?
(2) STABILITA' TEMPORALE: train 2018-20 vs 21-22; OOS 2023-11/2025-01 vs
2025-01/2026-05. Il miglioramento e' in OGNI finestra o concentrato?
(3) DIPENDENZA HURST (decisivo): rigenero i segnali con hurst_max=None (NESSUN
loss-guard, NON tocco la cache) e ripeto base-vs-policy. Se senza il guard la
policy crolla, funziona SOLO grazie al guard -> condizione di validita'.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, OOS_START_MS, _atr14 # noqa: E402
import importlib.util
spec = importlib.util.spec_from_file_location(
"cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py"))
cc16 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cc16)
CloseConfirmSl = cc16.CloseConfirmSl
from src.data.downloader import load_data # noqa: E402
from src.live.strategy_loader import load_strategy # noqa: E402
CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"]
ASSETS = ("BTC", "ETH")
# ---- policy ponte: SL fisso a multiplo di ATR (no_sl come limite) ----
class WideSlPolicy(ExitPolicy):
"""SL intrabar spostato a k*ATR oltre sl0 (ponte tra base e no-sl)."""
name = "wide_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(params.get("k_atr", 2.0))
self.atr = ctx["atr14"]
def levels(self, j: int):
a = self.atr[j - 1] if np.isfinite(self.atr[j - 1]) else 0.0
# sl0 e' sotto (long) / sopra (short) l'entry; allarga di k*atr
sl = self.sl0 - self.k * a if self.d == 1 else self.sl0 + self.k * a
return self.tp0, sl, 1.0
def sub(cls, sleeve, g, s, e):
return simulate(cls, sleeve, g, start_ms=s, end_ms=e)
def fmt(r):
if not r:
return " n/a"
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
def build_signals(hurst_max):
"""Rigenera sleeve in memoria con hurst_max dato (None = no guard). NON tocca cache."""
out = {}
params = dict(trend_max=3.0, ema_long=200, hurst_max=hurst_max, min_tp_frac=0.0015)
for code in CODES:
strat = load_strategy(code)
for asset in ASSETS:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
out[(code, asset)] = {
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
for s in sigs],
"open": df["open"].values.astype(float),
"high": h, "low": l, "close": c,
"ts_ms": df["timestamp"].values.astype(np.int64),
"atr14": _atr14(h, l, c),
}
return out
def main():
data = exit_lab.load_sleeves()
keys = list(data.keys())
# ===== TEST 1: JITTER PARAMETRI =====
print("=" * 100)
print("TEST 1 — JITTER buffer fuori griglia + ponte WIDE-SL (OOS, dopo 2023-11)")
print("=" * 100)
jit_buffers = [0.4, 0.6, 0.75, 1.0]
all_pos = True
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
line = f"{key:<10} BASE {fmt(base)}"
print(line)
for b in jit_buffers:
r = sub(CloseConfirmSl, sleeve, {"buffer": b}, OOS_START_MS, None)
better = r and base and r["sharpe_t"] >= base["sharpe_t"] - 0.10
all_pos &= bool(better)
print(f" buf={b:<4} {fmt(r)} {'OK' if better else 'WORSE'}")
print()
print(f"JITTER buffer: tutte >= base-0.10 sharpe? {all_pos}\n")
print("-" * 100)
print("PONTE WIDE-SL: SL intrabar fisso allargato a k*ATR (k grande -> verso no-sl)")
print("-" * 100)
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
print(f"{key:<10} BASE(k=0) {fmt(base)}")
for k in [1.5, 3.0, 4.0]:
r = sub(WideSlPolicy, sleeve, {"k_atr": k}, OOS_START_MS, None)
print(f" k={k:<4} {fmt(r)}")
print()
# ===== TEST 2: STABILITA' TEMPORALE =====
print("=" * 100)
print("TEST 2 — STABILITA' TEMPORALE (base vs policy buffer=0.5)")
print("=" * 100)
ms = lambda d: int(pd.Timestamp(d, tz="UTC").value // 1e6)
windows = [
("TRAIN 2018-20", None, ms("2021-01-01")),
("TRAIN 2021-22", ms("2021-01-01"), OOS_START_MS),
("OOS 23/11-25/01", OOS_START_MS, ms("2025-01-01")),
("OOS 25/01-26/05", ms("2025-01-01"), None),
]
win_verdict = {w[0]: 0 for w in windows}
win_total = {w[0]: 0 for w in windows}
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
print(f"\n{key}")
for wname, s, e in windows:
b = sub(ExitPolicy, sleeve, {}, s, e)
p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e)
if b and p:
win_total[wname] += 1
# criterio: policy non peggio della base su sharpe (tol 0.15)
imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15
win_verdict[wname] += int(imp)
tag = "OK " if imp else "BAD"
else:
tag = "n/a"
print(f" {wname:<18} base {fmt(b)}")
print(f" {'':<18} pol {fmt(p)} -> {tag}")
print("\nPer-finestra (policy >= base-0.15 sharpe):")
for w in windows:
wn = w[0]
print(f" {wn:<18} {win_verdict[wn]}/{win_total[wn]} sleeve OK")
# ===== TEST 3: DIPENDENZA HURST (DECISIVO) =====
print("\n" + "=" * 100)
print("TEST 3 — DIPENDENZA dal loss-guard HURST (DECISIVO)")
print("Rigenero i segnali con hurst_max=None (NO guard, regime trending incluso).")
print("Se la policy crolla -> funziona SOLO grazie al guard.")
print("=" * 100)
print("Generazione segnali SENZA hurst (puo' richiedere ~1-2 min)...")
data_nohurst = build_signals(hurst_max=None)
n_guard = sum(len(s["signals"]) for s in data.values())
n_nohurst = sum(len(s["signals"]) for s in data_nohurst.values())
print(f"Segnali totali: con guard {n_guard}, senza guard {n_nohurst} "
f"(+{n_nohurst - n_guard} segnali in regime trending)\n")
holds = True
for region_name, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
print(f"--- {region_name} (segnali SENZA hurst guard) ---")
for (code, asset), sleeve in data_nohurst.items():
key = f"{code.split('_')[0]} {asset}"
b = sub(ExitPolicy, sleeve, {}, s, e)
p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e)
if b and p:
imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15
ddimp = p["dd_pct"] <= b["dd_pct"] + 1.0
holds &= bool(imp)
tag = "OK " if imp else "POLICY WORSE"
else:
tag = "n/a"
print(f" {key:<10} base {fmt(b)}")
print(f" {'':<10} pol {fmt(p)} -> {tag}")
print()
print(f"TEST 3 verdict: policy regge SENZA il guard (>= base-0.15 sharpe ovunque)? {holds}")
print("Se False -> la tesi 'SL dannoso' dipende dal guard (condizione di validita').")
if __name__ == "__main__":
main()
@@ -0,0 +1,261 @@
"""VERIFY EXIT-16 close_confirm_sl — lente STRESS (avversariale).
Ipotesi nulla: l'edge della close-confirm-SL e' fragile a frizioni reali.
Quattro stress, tutti su segnali cache (params LIVE, hurst_max=0.55):
(1) FEE 2x: FEE_RT=0.002 (vs 0.001). Penalizza le policy che girano piu' capitale.
(2) BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag-21): worst-trade + 5 peggiori
trade della policy vs base. Lo SL disattivato lascia correre le perdite?
(3) SLIPPAGE AVVERSO 20bps sulle uscite della policy: ogni fill di USCITA paga
+20bps contro la posizione (prezzo di uscita peggiorato). L'edge regge?
NB: lo applico SOLO alle uscite della POLICY (la sua tesi e' "esco al close":
il close-fill e' market, paga slippage; la base esce a livelli limite sl0/tp0).
(4) OVERLAP/TURNOVER: la policy allunga la permanenza (no stop intrabar). Conto
i segnali SALTATI per non-overlap (i <= last_exit) base vs policy, e quanto
capitale-tempo (somma bars in posizione) gira in piu'.
Tutto via simulate() con monkeypatch di FEE_RT e una sottoclasse engine per lo
slippage. Niente modifiche ad altri file.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402
import importlib.util
spec = importlib.util.spec_from_file_location(
"cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py"))
cc16 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cc16)
CloseConfirmSl = cc16.CloseConfirmSl
BUF = 0.5 # train-pick
def fmt(r):
if not r:
return " n/a"
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} win{r['win_pct']:>4.0f} bars{r['avg_bars']:>5.1f}")
def sub(cls, sleeve, g, s, e):
return simulate(cls, sleeve, g, start_ms=s, end_ms=e)
def ms(d):
return int(pd.Timestamp(d, tz="UTC").value // 1e6)
# ===========================================================================
# Engine "instrumented" che riproduce simulate() ma:
# - applica uno slippage avverso (bps) su OGNI fill di USCITA (solo se policy)
# - raccoglie la lista dei ret per-trade e i segnali SALTATI per non-overlap
# - raccoglie capital-time (somma bars)
# Lo tengo allineato 1:1 con exit_lab.simulate (stesso ordine SL-prima-di-TP).
# ===========================================================================
def simulate_instr(policy_cls, sleeve, params=None, start_ms=None, end_ms=None,
exit_slip_bps=0.0):
params = params or {}
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * LEV
slip = exit_slip_bps * 1e-4
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
bars_tot = 0
skipped_overlap = 0
rets = [] # (ret, ts_entry, bars)
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i + 1 >= n:
continue
if i <= last_exit:
skipped_overlap += 1
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
# slippage avverso sull'uscita: il prezzo di uscita peggiora di slip,
# cioe' si vende piu' basso (long) / si ricompra piu' alto (short).
def adj(p):
return p * (1.0 - slip) if d == 1 else p * (1.0 + slip)
ret = sum(f * (adj(p) - entry) for f, p in fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append((ret, int(ts[i]), j - i))
if trades == 0:
return {}
r = np.array([x[0] for x in rets])
return {
"ret_pct": (capital / 1000.0 - 1) * 100,
"dd_pct": max_dd * 100,
"trades": trades,
"win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades,
"bars_tot": bars_tot,
"skipped_overlap": skipped_overlap,
"rets": rets,
"worst5": sorted(r.tolist())[:5],
}
def main():
data = exit_lab.load_sleeves()
# ===================================================================
print("=" * 104)
print("TEST 1 — FEE 2x (FEE_RT 0.001 -> 0.002). base vs policy buffer=0.5 (OOS, dopo 2023-11)")
print("=" * 104)
orig_fee = exit_lab.FEE_RT
survive_fee = True
for fee in (0.001, 0.002):
exit_lab.FEE_RT = fee
print(f"\n--- FEE_RT={fee} ---")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
p = sub(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None)
tag = ""
if fee == 0.002 and b and p:
# regge se sharpe policy >= base (la tesi e' che migliora)
ok = p["sharpe_t"] >= b["sharpe_t"] - 0.10
survive_fee &= ok
tag = "OK" if ok else "WORSE"
print(f" {key:<10} base {fmt(b)}")
print(f" {'':<10} pol {fmt(p)} {tag}")
exit_lab.FEE_RT = orig_fee
print(f"\nFEE 2x: policy regge (>= base-0.10 sh su tutti gli sleeve OOS)? {survive_fee}")
# ===================================================================
print("\n" + "=" * 104)
print("TEST 2 — BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag): worst-trade + 5 peggiori")
print("=" * 104)
s2, e2 = ms("2021-01-01"), ms("2023-01-01")
tail_worse = 0
tail_total = 0
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate_instr(ExitPolicy, sleeve, {}, s2, e2)
p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, s2, e2)
print(f"\n{key}")
print(f" base {fmt(b)}")
print(f" pol {fmt(p)}")
if b and p:
bw = [f"{x*100:+.1f}%" for x in b["worst5"]]
pw = [f"{x*100:+.1f}%" for x in p["worst5"]]
print(f" base 5 peggiori (ret netto): {bw}")
print(f" pol 5 peggiori (ret netto): {pw}")
tail_total += 1
# la policy peggiora la coda se il worst-trade e' piu' negativo
if p["worst5"][0] < b["worst5"][0] - 0.005:
tail_worse += 1
print(f" -> CODA PEGGIORE: worst {p['worst5'][0]*100:+.1f}% < base {b['worst5'][0]*100:+.1f}%")
else:
print(f" -> coda OK: worst {p['worst5'][0]*100:+.1f}% vs base {b['worst5'][0]*100:+.1f}%")
print(f" DD bear: base {b['dd_pct']:.1f}% pol {p['dd_pct']:.1f}%")
print(f"\nBEAR: sleeve con coda PEGGIORE (worst-trade > 0.5pt sotto base): {tail_worse}/{tail_total}")
# ===================================================================
print("\n" + "=" * 104)
print("TEST 3 — SLIPPAGE AVVERSO 20bps sulle uscite della POLICY (OOS). base senza slippage")
print("(la tesi della policy e' 'esco al close' = market fill -> paga slippage)")
print("=" * 104)
survive_slip = True
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=0.0)
p0 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=0.0)
p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0)
ok = p20 and b and p20["sharpe_t"] >= b["sharpe_t"] - 0.10
survive_slip &= bool(ok)
print(f"\n{key}")
print(f" base (no slip) {fmt(b)}")
print(f" pol (no slip) {fmt(p0)}")
print(f" pol (+20bps exit) {fmt(p20)} {'OK' if ok else 'WORSE vs base'}")
print(f"\nSLIPPAGE 20bps: policy ancora >= base-0.10 sh su tutti? {survive_slip}")
print("(test severo: lo slippage colpisce la policy ma NON la base — asimmetria pessimistica)")
# severita' extra: slippage anche sulla base (entrambe market) per fairness
print("\n--- fairness: 20bps anche sulle uscite della BASE ---")
fair = True
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b20 = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=20.0)
p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0)
ok = p20 and b20 and p20["sharpe_t"] >= b20["sharpe_t"] - 0.10
fair &= bool(ok)
print(f" {key:<10} base+20 {fmt(b20)}")
print(f" {'':<10} pol +20 {fmt(p20)} {'OK' if ok else 'WORSE'}")
print(f"fairness (entrambe +20bps): policy >= base-0.10 sh? {fair}")
# ===================================================================
print("\n" + "=" * 104)
print("TEST 4 — OVERLAP/TURNOVER: segnali saltati per non-overlap + capital-time (OOS)")
print("=" * 104)
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None)
p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None)
if b and p:
dskip = p["skipped_overlap"] - b["skipped_overlap"]
dbars = p["bars_tot"] - b["bars_tot"]
print(f" {key:<10} base: trades {b['trades']:>4} skip-overlap {b['skipped_overlap']:>4} "
f"bars_tot {b['bars_tot']:>6} avg {b['avg_bars']:.1f}")
print(f" {'':<10} pol : trades {p['trades']:>4} skip-overlap {p['skipped_overlap']:>4} "
f"bars_tot {p['bars_tot']:>6} avg {p['avg_bars']:.1f}")
print(f" {'':<10} -> +{dskip} segnali persi per overlap, "
f"+{dbars} bars in posizione ({dbars/max(b['bars_tot'],1)*100:+.0f}% capital-time)")
if __name__ == "__main__":
main()
@@ -0,0 +1,222 @@
"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' per EXIT-22 (no_sl).
Ipotesi da confutare: "rimuovere lo SL e' un free-lunch" potrebbe essere un
artefatto di (a) look-ahead nel contratto livelli/engine, (b) dipendenza dal
timing perfetto dell'uscita, (c) non-replicabilita' del fill live (il worker
ticka ogni ora ed esce al poll successivo, non al close esatto del bar).
Esperimenti (tutti riusano simulate() e la policy importata):
E1 CONTRATTO: la policy NoSl ha livelli STATICI dall'entrata. Confermo che
l'output non cambia se "rumorizzo" gli array > i (futuro) post-entrata, e
che non cambia se rumorizzo atr14 ovunque (la policy non lo usa).
E2 LAG: variante che ritarda l'uscita a horizon (e i tocchi) di 1 bar — ma
siccome NoSl non ha SL e non usa indicatori a j-1, il vero lag rilevante
e' SUL FILL. Implemento NoSlLagExit: l'uscita al close del bar j viene
eseguita al close[j+1] (un tick dopo) e il TP intrabar viene fillato al
WORST fra tp e close[j] (slippage avverso). Misuro il collasso dell'edge.
E3 ESEGUIBILITA' open[j+1]: orizzonte/uscite al close[j] rieseguite a
open[j+1] (il poll successivo del worker). Quanto costa il gap di apertura?
E4 TP-FILL pessimistico: TP fillato a close[j] (non al livello tp) quando il
bar tocca il TP -> stima il caso in cui il worker scopre il tocco solo al
poll e chiude al prezzo corrente, peggiore del livello.
E5 SOTTOPERIODI OOS: l'edge di 'none' regge nei sotto-intervalli OOS o e'
tutto in una coda fortunata? (2023-11..2024-08 vs 2024-08..fine).
Verdetto: refuted=True solo se un test ESEGUIBILE realistico (E2/E3/E4) cancella
il vantaggio di 'none' su 'base' (ret E dd) su entrambi gli asset / tutte le fade.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # scripts/analysis
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, load_sleeves, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402
sys.path.insert(0, str(HERE))
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location("_no_sl", HERE / "22_no_sl.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
NoSl = _mod.NoSl
def _fmt(r):
if not r:
return " (no trades)"
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} win{r['win_pct']:>4.0f}% bars{r['avg_bars']:>5.1f}")
# ---------------------------------------------------------------- E2/E3/E4 engines
def simulate_exec(sleeve, mode, *, exit_at, tp_fill, start_ms=None, end_ms=None,
with_sl=False):
"""Clone di simulate() con NoSl(mode), ma con esecuzione LIVE-realistica:
exit_at : 'close' -> uscita orizzonte al close[j] (baseline harness)
'open1' -> uscita orizzonte al open[j+1] (poll successivo)
tp_fill : 'level' -> TP fillato al livello tp (ottimistico, harness)
'close' -> TP fillato al close[j] del bar che tocca (worker
scopre il tocco solo al poll: prezzo corrente)
'open1' -> TP fillato al open[j+1]
with_sl=False -> NoSl (mode='none'); with_sl tramite mode='base' usa lo SL
della strategia (per il confronto base vs none nelle STESSE condizioni exec).
"""
h, l, c, o, ts = (sleeve["high"], sleeve["low"], sleeve["close"],
sleeve["open"], sleeve["ts_ms"])
n = len(c)
fee = exit_lab.FEE_RT * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = bars_tot = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
if mode == "base":
sl = sl0
else:
sl = None # 'none'
tp = tp0
horizon = min(int(mb), HARD_CAP)
exit_price = None
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
exit_price = c[j]
break
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl:
exit_price = sl # SL fill al livello (favorevole alla tesi 'base')
break
if hit_tp:
if tp_fill == "level":
exit_price = tp
elif tp_fill == "close":
exit_price = c[j]
elif tp_fill == "open1":
exit_price = o[j + 1] if j + 1 < n else c[j]
break
if step == horizon:
if exit_at == "close":
exit_price = c[j]
elif exit_at == "open1":
exit_price = o[j + 1] if j + 1 < n else c[j]
break
if exit_price is None:
exit_price = c[j]
ret = (exit_price - entry) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
"trades": trades, "win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades}
def main():
data = load_sleeves()
keys = list(data.keys())
# ---- E1: contratto look-ahead (la policy ha livelli statici) -------------
print("=" * 100)
print("E1 CONTRATTO LOOK-AHEAD: rumorizzo gli array DOPO l'entrata e atr14 (la")
print(" policy NoSl non deve cambiare: livelli statici, non usa indicatori).")
print("=" * 100)
rng = np.random.default_rng(0)
code, asset = "MR02_donchian_fade", "ETH"
base_sleeve = data[(code, asset)]
clean = simulate(NoSl, base_sleeve, {"mode": "none"}, start_ms=OOS_START_MS)
# rumorizzo atr14 interamente (policy non lo usa)
noisy = dict(base_sleeve)
noisy["atr14"] = base_sleeve["atr14"] * (1 + rng.normal(0, 0.5, len(base_sleeve["atr14"])))
res_noisy_atr = simulate(NoSl, noisy, {"mode": "none"}, start_ms=OOS_START_MS)
print(f" {code.split('_')[0]} {asset} clean : {_fmt(clean)}")
print(f" {code.split('_')[0]} {asset} atr14 noised : {_fmt(res_noisy_atr)} "
f"(identico => NoSl non legge atr14)")
# ---- E5: sottoperiodi OOS ------------------------------------------------
print("\n" + "=" * 100)
print("E5 SOTTOPERIODI OOS: l'edge di 'none' vs 'base' regge in 2 meta'?")
print("=" * 100)
mid = int(pd.Timestamp("2024-09-01", tz="UTC").value // 1e6)
for (code, asset) in keys:
sl_name = code.split("_")[0]
s = data[(code, asset)]
for lab, a, b in [("H1 23-11..24-09", OOS_START_MS, mid),
("H2 24-09..fine ", mid, None)]:
bse = simulate(ExitPolicy, s, {}, start_ms=a, end_ms=b)
non = simulate(NoSl, s, {"mode": "none"}, start_ms=a, end_ms=b)
if bse and non:
dret = non["ret_pct"] - bse["ret_pct"]
ddd = non["dd_pct"] - bse["dd_pct"]
flag = "OK" if (dret > -1 and ddd < 1) else "FAIL"
print(f" {sl_name} {asset} {lab}: base {_fmt(bse)}")
print(f" {sl_name} {asset} {lab}: none {_fmt(non)} "
f"[dret{dret:+.0f} ddd{ddd:+.1f} {flag}]")
# ---- E2/E3/E4: esecuzione realistica, base vs none nelle STESSE condizioni
print("\n" + "=" * 100)
print("E2/E3/E4 ESEGUIBILITA' LIVE (OOS). Confronto base vs none sotto:")
print(" IDEAL : exit close[j], TP@level (= harness)")
print(" OPEN1 : exit open[j+1], TP@open[j+1] (worker esce al poll successivo)")
print(" TPCLOSE: exit close[j], TP@close[j] (TP scoperto al poll, fill peggiore)")
print("=" * 100)
scenarios = [("IDEAL ", dict(exit_at="close", tp_fill="level")),
("OPEN1 ", dict(exit_at="open1", tp_fill="open1")),
("TPCLOSE", dict(exit_at="close", tp_fill="close"))]
summary = {sc[0]: {"none_wins_ret": 0, "none_wins_dd": 0, "tot": 0} for sc in scenarios}
for (code, asset) in keys:
sl_name = code.split("_")[0]
s = data[(code, asset)]
print(f"\n --- {sl_name} {asset} (OOS) ---")
for scname, kw in scenarios:
b = simulate_exec(s, "base", start_ms=OOS_START_MS, **kw)
nn = simulate_exec(s, "none", start_ms=OOS_START_MS, **kw)
if not b or not nn:
continue
dret = nn["ret_pct"] - b["ret_pct"]
ddd = nn["dd_pct"] - b["dd_pct"]
summary[scname]["tot"] += 1
summary[scname]["none_wins_ret"] += dret > -1
summary[scname]["none_wins_dd"] += ddd < 1
print(f" {scname} base: {_fmt(b)}")
print(f" {scname} none: {_fmt(nn)} [dret{dret:+.0f} ddd{ddd:+.1f}]")
print("\n" + "=" * 100)
print("VERDETTO ESEGUIBILITA' (none >= base su ret e dd, per scenario):")
for scname, kw in scenarios:
s = summary[scname]
print(f" {scname}: none vince-o-pareggia ret {s['none_wins_ret']}/{s['tot']}, "
f"dd {s['none_wins_dd']}/{s['tot']}")
print("=" * 100)
if __name__ == "__main__":
main()
@@ -0,0 +1,219 @@
"""VERIFICATORE AVVERSARIALE OVERFIT — EXIT-22 no_sl.
Tesi del sopravvissuto: "rimuovere lo SL intrabar (resta TP+max_bars) MIGLIORA
ret E DD E Sharpe su tutte le 6 sleeve, plateau monotono tight<base<wide<none".
Ipotesi avversaria (da confutare o confermare):
(A) JITTER: il plateau none>wide>base e' monotono? Aggiungo ponti 3x/4x.
Se la curva e' monotona e satura (3x~4x~none), il plateau e' robusto;
se none e' un picco isolato oltre wide, sospetto.
(B) STABILITA' TEMPORALE: spezzo train (2018-20 vs 21-22) e OOS
(2023-11..2025-01 vs 2025-01..2026-05). Il guadagno c'e' in OGNI
finestra o e' concentrato in un solo regime?
(C) DIPENDENZA HURST (DECISIVO): i segnali in cache hanno hurst_max=0.55
(loss-guard toglie il regime persistente/trending — proprio dove gli SL
servono). Rigenero i segnali SENZA hurst (in memoria, cache intatta) e
ripeto base-vs-none. Se senza guard la policy crolla -> funziona SOLO
grazie al guard => condizione di validita'.
Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_22_no_sl_overfit.py
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
import exit_lab # noqa: E402
from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402
CODES, ASSETS, LIVE_PARAMS, _atr14)
from src.data.downloader import load_data # noqa: E402
from src.live.strategy_loader import load_strategy # noqa: E402
# import della policy DAL SUO FILE (no copia)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import importlib.util
_spec = importlib.util.spec_from_file_location(
"policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py"))
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
NoSl = _mod.NoSl
# ---- policy ponte: SL a scale arbitrario (per jitter 3x/4x) -----------------
class NoSlScale(ExitPolicy):
name = "no_sl_scale"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
scale = params.get("scale", None)
self.sl = None if scale is None else entry + float(scale) * (sl0 - entry)
def levels(self, j: int):
return self.tp0, self.sl, 1.0
def _fmt(r):
if not r:
return " (no trades)"
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>6.2f} sh{r['sharpe_t']:>6.2f} "
f"n{r['trades']:>4} win{r['win_pct']:>5.1f} bars{r['avg_bars']:>5.1f}")
# =============================================================================
# (A) JITTER: ponti di scala 1x(base) 1.5 2 3 4 none
# =============================================================================
def test_jitter(data):
print("\n" + "=" * 78)
print("(A) JITTER PLATEAU — scala SL: base(1x) 1.5x 2x 3x 4x none [OOS]")
print("=" * 78)
scales = [("base", {"scale": 1.0}), ("1.5x", {"scale": 1.5}),
("2x(wide)", {"scale": 2.0}), ("3x", {"scale": 3.0}),
("4x", {"scale": 4.0}), ("none", {"scale": None})]
monotonic_fail = 0
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
print(f"\n{key} [OOS 2023-11 ->]")
rets, dds, shs = [], [], []
for tag, g in scales:
r = simulate(NoSlScale, sleeve, g, start_ms=OOS_START_MS)
rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0))
shs.append(r.get("sharpe_t", 0))
print(f" {tag:<10}{_fmt(r)}")
# plateau monotono atteso: ret crescente, dd decrescente, sh crescente
ret_mono = all(rets[i] <= rets[i + 1] + 1e-6 for i in range(len(rets) - 1))
sh_mono = all(shs[i] <= shs[i + 1] + 1e-6 for i in range(len(shs) - 1))
# saturazione: none vs 4x ravvicinati (plateau "piatto" in cima)?
sat = abs(rets[-1] - rets[-2]) / (abs(rets[-1]) + 1e-9) * 100
print(f" -> ret monotono crescente: {ret_mono} | sharpe monotono: {sh_mono}"
f" | gap none-vs-4x: {sat:.1f}%")
if not (ret_mono and sh_mono):
monotonic_fail += 1
print(f"\n Sleeve con plateau NON monotono: {monotonic_fail}/6")
return monotonic_fail
# =============================================================================
# (B) STABILITA' TEMPORALE: sotto-finestre train e OOS
# =============================================================================
def test_temporal(data):
print("\n" + "=" * 78)
print("(B) STABILITA' TEMPORALE — base vs none in 4 sotto-finestre")
print("=" * 78)
ms = lambda s: int(pd.Timestamp(s, tz="UTC").value // 1e6)
wins = [
("TR 2018-2020", None, ms("2021-01-01")),
("TR 2021-2022", ms("2021-01-01"), OOS_START_MS),
("OOS 23-11..25-01", OOS_START_MS, ms("2025-01-01")),
("OOS 25-01..26-05", ms("2025-01-01"), None),
]
# conta in quante (sleeve x finestra) none batte base su ret E dd E sh
cells = 0
none_better_all = 0
none_worse_dd = 0
for wname, s0, s1 in wins:
print(f"\n--- finestra {wname} ---")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, {}, start_ms=s0, end_ms=s1)
nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=s0, end_ms=s1)
if not b or not nn:
print(f" {key:<10} (campione vuoto)")
continue
cells += 1
d_ret = nn["ret_pct"] - b["ret_pct"]
d_dd = nn["dd_pct"] - b["dd_pct"] # <0 = none meglio (dd minore)
d_sh = nn["sharpe_t"] - b["sharpe_t"]
allbetter = d_ret > 0 and d_dd < 0 and d_sh > 0
none_better_all += allbetter
none_worse_dd += d_dd > 1e-6
flag = "OK" if allbetter else ("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-")
print(f" {key:<10} base ret{b['ret_pct']:>7.0f} dd{b['dd_pct']:>5.1f} "
f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>7.0f} "
f"dd{nn['dd_pct']:>5.1f} sh{nn['sharpe_t']:>5.2f} | "
f"dRet{d_ret:>+7.0f} dDD{d_dd:>+5.1f} dSh{d_sh:>+5.2f} [{flag}]")
print(f"\n none meglio su (ret&dd&sh) in {none_better_all}/{cells} celle "
f"sleeve x finestra | none PEGGIORA il DD in {none_worse_dd}/{cells}")
return none_better_all, none_worse_dd, cells
# =============================================================================
# (C) DECISIVO: segnali SENZA hurst (in memoria, cache intatta)
# =============================================================================
def build_sleeves_no_hurst():
"""Rigenera i segnali con hurst_max=None (loss-guard OFF). NON tocca la
cache su disco: ritorna un dict identico in forma a load_sleeves()."""
params = dict(LIVE_PARAMS)
params["hurst_max"] = None
out = {}
for code in CODES:
strat = load_strategy(code)
for asset in ASSETS:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
out[(code, asset)] = {
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
for s in sigs],
"open": df["open"].values.astype(float),
"high": h, "low": l, "close": c,
"ts_ms": df["timestamp"].values.astype(np.int64),
"atr14": _atr14(h, l, c),
}
return out
def test_hurst_dependency(data_guard):
print("\n" + "=" * 78)
print("(C) DECISIVO — DIPENDENZA DAL FILTRO HURST")
print(" rigenero segnali con hurst_max=None (guard OFF), confronto base vs none")
print("=" * 78)
data_noh = build_sleeves_no_hurst()
# quanto cambia il numero di segnali (il guard quanti ne toglieva?)
print("\nConteggio segnali (guard ON cache vs guard OFF):")
for k in data_guard:
ng = len(data_guard[k]["signals"])
nh = len(data_noh[k]["signals"])
print(f" {k[0].split('_')[0]} {k[1]:<4} guard ON {ng:>5} OFF {nh:>5} "
f"(+{nh - ng} segnali tossici reintrodotti)")
for label, ms0 in [("FULL", None), ("OOS", OOS_START_MS)]:
print(f"\n--- {label} (guard OFF) base vs none ---")
none_better_all = none_worse_dd = none_worse_ret = cells = 0
for (code, asset), sleeve in data_noh.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, {}, start_ms=ms0)
nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=ms0)
if not b or not nn:
continue
cells += 1
d_ret = nn["ret_pct"] - b["ret_pct"]
d_dd = nn["dd_pct"] - b["dd_pct"]
d_sh = nn["sharpe_t"] - b["sharpe_t"]
none_better_all += (d_ret > 0 and d_dd < 0 and d_sh > 0)
none_worse_dd += d_dd > 1e-6
none_worse_ret += d_ret <= 0
flag = "OK" if (d_ret > 0 and d_dd < 0 and d_sh > 0) else \
("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-")
print(f" {key:<10} base ret{b['ret_pct']:>8.0f} dd{b['dd_pct']:>6.1f} "
f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>8.0f} "
f"dd{nn['dd_pct']:>6.1f} sh{nn['sharpe_t']:>5.2f} | "
f"dRet{d_ret:>+8.0f} dDD{d_dd:>+6.1f} dSh{d_sh:>+5.2f} [{flag}]")
print(f" => guard OFF {label}: none meglio (ret&dd&sh) {none_better_all}/{cells}"
f" | none PEGGIORA dd {none_worse_dd}/{cells} | PEGGIORA ret {none_worse_ret}/{cells}")
return data_noh
if __name__ == "__main__":
data = load_sleeves()
mono_fail = test_jitter(data)
nb_all, nb_dd, cells = test_temporal(data)
test_hurst_dependency(data)
@@ -0,0 +1,327 @@
"""VERIFICA AVVERSARIALE — lente STRESS sul sopravvissuto EXIT-22 (mode='none', SL RIMOSSO).
Ipotesi avversaria: il vantaggio di togliere lo SL (ret/DD/Sharpe migliori) e' un
ARTEFATTO del regime calmo OOS 2024-25 e/o si dissolve sotto stress realistici. Senza
SL la coda non e' limitata a leva 3x: un trade puo' perdere fino al movimento avverso
intero entro max_bars (24h). Testo 4 stress:
(1) FEE 2x (FEE_RT=0.002): la policy ABBASSA il turnover (avg_bars 9->12-13, meno
trade) quindi la fee 2x dovrebbe penalizzarla MENO della base -> non e' la sua
debolezza, ma lo misuro comunque per onesta'.
(2) SOTTOPERIODO BEAR 2021-01..2022-12 (crash 2021-05-19, LUNA mag-2022, FTX nov-2022):
qui lo SL dovrebbe servire. Confronto ret/DD + WORST trade + 5 peggiori trade
none vs base. E' il test decisivo: se none esplode in DD o ha code mostruose qui,
il survivor e' confutato (l'OOS calmo nascondeva il rischio).
(3) SLIPPAGE AVVERSO 20bps sulle USCITE: ogni uscita (TP/horizon) pagata 20bps peggio
(prezzo di uscita penalizzato del 20bps contro la posizione). Penalizza di piu' le
policy che escono al close/horizon (none esce piu' spesso a horizon, non al TP-limit).
(4) OVERLAP/CHURN: la policy allunga la permanenza -> conto i segnali SCARTATI per
non-overlap (i<=last_exit) in piu' rispetto alla base, e l'effetto sul capitale.
Confuto SOLO con evidenza numerica concreta.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, load_sleeves, HARD_CAP, LEV, POS # noqa: E402
# import della policy dal suo file
import importlib.util
_spec = importlib.util.spec_from_file_location(
"policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py"))
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
NoSl = _mod.NoSl
BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)
BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6) # esclusivo
OOS_START = exit_lab.OOS_START_MS
DATA = load_sleeves()
def line(tag, key, r):
if not r:
print(f" {tag:<14}{key:<10} (no trades)")
return
print(f" {tag:<14}{key:<10} ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} "
f"sh{r['sharpe_t']:>5.2f} n{r['trades']:>4} win{r['win_pct']:>4.0f}% "
f"bars{r['avg_bars']:>5.1f}")
# ----------------------------------------------------------- (1) FEE 2x
def test_fee2x():
print("\n=== (1) FEE 2x (0.10%->0.20% RT x leva 3) ===")
orig = exit_lab.FEE_RT
for fee, label in [(0.001, "fee1x"), (0.002, "fee2x")]:
exit_lab.FEE_RT = fee
print(f" -- {label} --")
n_better_none = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, start_ms=OOS_START)
no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START)
flag = " <none>=base" if (no and b and no["ret_pct"] >= b["ret_pct"]
and no["dd_pct"] <= b["dd_pct"]) else ""
if flag:
n_better_none += 1
print(f" {key:<10} base ret{b['ret_pct']:>6.0f}% dd{b['dd_pct']:>5.1f} | "
f"none ret{no['ret_pct']:>6.0f}% dd{no['dd_pct']:>5.1f}{flag}")
print(f" -> none >= base (ret&dd) su {n_better_none}/6 sleeve")
exit_lab.FEE_RT = orig
# ----------------------------------------------------------- (2) BEAR 2021-22
def worst_trades(policy_cls, sleeve, params, start_ms, end_ms, k=5):
"""Replica la logica di simulate ma raccoglie i ret per-trade per
estrarre i k peggiori e il worst. Stessa identica meccanica."""
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * LEV
last_exit = -1
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms or ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
rets.append(ret * 1e4) # bps
last_exit = j
rets = np.array(sorted(rets))
return rets
def test_bear():
print("\n=== (2) SOTTOPERIODO BEAR 2021-01..2022-12 (LUNA/FTX/19-may-2021) ===")
print(" (ret/DD su periodo + WORST trade e media 5-peggiori, bps; leva 3x)")
agg_none_dd = []
agg_base_dd = []
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, start_ms=BEAR_START, end_ms=BEAR_END)
no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=BEAR_START, end_ms=BEAR_END)
rb = worst_trades(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END)
rn = worst_trades(NoSl, sleeve, {"mode": "none"}, BEAR_START, BEAR_END)
wb = rb[0] if len(rb) else float("nan")
wn = rn[0] if len(rn) else float("nan")
w5b = rb[:5].mean() if len(rb) >= 5 else float("nan")
w5n = rn[:5].mean() if len(rn) >= 5 else float("nan")
if b:
agg_base_dd.append(b["dd_pct"])
if no:
agg_none_dd.append(no["dd_pct"])
print(f" {key:<10}")
print(f" base ret{b.get('ret_pct',0):>6.0f}% dd{b.get('dd_pct',0):>5.1f} "
f"n{b.get('trades',0):>3} | worst{wb:>8.0f}bps 5worst{w5b:>8.0f}bps")
print(f" none ret{no.get('ret_pct',0):>6.0f}% dd{no.get('dd_pct',0):>5.1f} "
f"n{no.get('trades',0):>3} | worst{wn:>8.0f}bps 5worst{w5n:>8.0f}bps "
f"{'TAIL+' if wn < wb - 1 else ''}")
print(f" -> DD medio bear: base {np.mean(agg_base_dd):.1f}% none {np.mean(agg_none_dd):.1f}%")
print(f" worst-DD bear: base {np.max(agg_base_dd):.1f}% none {np.max(agg_none_dd):.1f}%")
# ----------------------------------------------------------- (3) SLIPPAGE 20bps exit
class NoSlSlip(NoSl):
"""none + slippage avverso 20bps su OGNI uscita (sia TP che horizon/close).
Implementato penalizzando i livelli: tp eseguito 20bps peggio, e l'uscita al
close subisce un haircut equivalente applicato nel ret. Per semplicita' e
fedelta', applico lo slippage al RET finale come costo per-trade extra: ogni
trade paga 20bps di slippage sul notional (una uscita per trade)."""
name = "no_sl_slip"
class BaseSlip(ExitPolicy):
name = "base_slip"
def simulate_slip(policy_cls, sleeve, params, start_ms, slip_bps=20.0):
"""simulate con costo di uscita avverso slip_bps (in bps di prezzo) applicato
al ret per-trade (oltre alla fee). Penalizza uscite al close E al TP."""
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * LEV
slip = slip_bps / 1e4 * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee - slip
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
"trades": trades, "win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0}
def test_slippage():
print("\n=== (3) SLIPPAGE AVVERSO 20bps su ogni uscita (OOS 2023-11+) ===")
print(" confronto: none-noslip vs none-slip20 vs base-slip20")
n_survive = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
no0 = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START)
nos = simulate_slip(NoSl, sleeve, {"mode": "none"}, OOS_START, 20.0)
bas = simulate_slip(ExitPolicy, sleeve, {}, OOS_START, 20.0)
surv = nos and bas and nos["ret_pct"] >= bas["ret_pct"] and nos["dd_pct"] <= bas["dd_pct"]
if surv:
n_survive += 1
print(f" {key:<10} none ret{no0['ret_pct']:>6.0f}% | none+slip ret{nos['ret_pct']:>6.0f}%"
f" dd{nos['dd_pct']:>5.1f} sh{nos['sharpe_t']:>5.2f} | base+slip ret{bas['ret_pct']:>6.0f}%"
f" dd{bas['dd_pct']:>5.1f} {'EDGE' if surv else 'lost'}")
print(f" -> none+slip >= base+slip (ret&dd) su {n_survive}/6 sleeve")
# ----------------------------------------------------------- (4) OVERLAP / CHURN
def count_dropped(policy_cls, sleeve, params, start_ms):
"""Conta i segnali scartati per non-overlap (i<=last_exit) e quelli eseguiti."""
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
last_exit = -1
dropped = executed = 0
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms or i + 1 >= n:
continue
if i <= last_exit:
dropped += 1
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
break
if step == horizon:
break
last_exit = j
executed += 1
return executed, dropped
def test_overlap():
print("\n=== (4) OVERLAP / CHURN (OOS 2023-11+) — segnali persi per non-overlap ===")
tot_extra = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
eb, db = count_dropped(ExitPolicy, sleeve, {}, OOS_START)
en, dn = count_dropped(NoSl, sleeve, {"mode": "none"}, OOS_START)
extra = dn - db
tot_extra += extra
print(f" {key:<10} base exec{eb:>4} drop{db:>3} | none exec{en:>4} drop{dn:>3} "
f"| extra-drop {extra:+d}")
print(f" -> segnali extra persi per overlap (none vs base): {tot_extra:+d} totali")
print(" (capitale gira meno: trade piu' lunghi, ma none rende comunque di piu' net)")
if __name__ == "__main__":
test_fee2x()
test_bear()
test_slippage()
test_overlap()
print("\n[fatto] verifica stress EXIT-22 no_sl completata.")
@@ -0,0 +1,335 @@
"""TAIL-RISK AUDIT of EXIT-22 (no_sl) and EXIT-16 (close_confirm_sl).
Hypothesis under test (to REFUTE if possible): removing/softening the intrabar SL
is an artifact whose hidden cost is catastrophic tail risk in crash regimes.
Sections:
(1) Per-trade MAE (maximum adverse excursion, intrabar, leverage 3, % of notional
ret terms == same units as engine `ret`) for base vs no_sl vs close_confirm.
Distribution p50/p95/p99/max per sleeve.
(2) Crash windows: 2020-03-12, 2021-05-19, 2022-06, 2022-11 (FTX). Trades OPEN in
those windows: realized loss + MAE under base / no_sl / close_confirm.
(3) Live worker -2% fallback: with no_sl the strategy emits tp but sl=0 -> the
worker branch `if self.tp and self.sl` is False, falls to `elif self.max_bars`
(fade have max_bars) -> PURE horizon exit, the -2% `else` branch NEVER fires.
So no_sl LIVE has NO stop at all. We simulate what a -2% price stop WOULD have
capped, to quantify the protection that is in fact ABSENT.
(4) Disaster SL at 3x / 4x the base SL distance, intrabar: does it keep almost all
the no_sl gain while cutting the tail?
Run: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_tail_risk.py
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab as EL # noqa: E402
from exit_lab import ExitPolicy, simulate, load_sleeves, LEV, OOS_START_MS # noqa: E402
DATA = load_sleeves()
SLEEVES = list(DATA.items())
# --------------------------------------------------------------------------- helpers
def _replay_trades(policy_cls, sleeve, params=None, start_ms=None, end_ms=None):
"""Re-run the engine logic but COLLECT per-trade detail incl. MAE.
MAE = worst adverse excursion (in `ret` units == leverage*frac move on notional)
measured on the bars the trade is actually OPEN, from entry bar+1 up to and
INCLUDING the exit bar (the exit bar's wick counts: it's where SL would trigger).
"""
params = params or {}
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = EL.FEE_RT * LEV
last_exit = -1
out = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), EL.HARD_CAP)
fills = []
remaining = 1.0
j = i
worst = 0.0 # most negative excursion in ret units
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
# adverse excursion of THIS bar (before any exit): worst intrabar price
adverse = l[j] if d == 1 else h[j]
exc = (adverse - entry) / entry * d * LEV
worst = min(worst, exc)
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
last_exit = j
out.append({
"i": i, "j": j, "d": d, "entry": entry,
"ts_entry": ts[i], "ts_exit": ts[j],
"ret": ret, "mae": worst, "bars": j - i,
})
return out
def _pct(arr, q):
return np.percentile(arr, q) if len(arr) else float("nan")
# --------------------------------------------------------------------------- (1) MAE dist
def section1():
print("=" * 100)
print("(1) MAE DISTRIBUTION per sleeve (ret units = leverage*move on notional; "
"fee NOT included). Negative = adverse.")
print(" MAE is the worst intrabar excursion BEFORE exit. For base, SL caps it; "
"for no_sl/close_confirm it can run.")
print("=" * 100)
policies = _load_policies()
hdr = f"{'sleeve':<11}{'policy':<16}{'n':>5}{'p50':>9}{'p95':>9}{'p99':>9}{'max':>9}{'realP99':>10}{'realMax':>10}"
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
print(f"\n--- {key}")
print(hdr)
for pname, (cls, prm) in policies.items():
tr = _replay_trades(cls, sleeve, prm)
mae = np.array([t["mae"] for t in tr]) * 100 # to %
rets = np.array([t["ret"] for t in tr]) * 100
print(f"{'':<11}{pname:<16}{len(tr):>5}"
f"{_pct(mae,50):>9.2f}{_pct(mae,5):>9.2f}{_pct(mae,1):>9.2f}{mae.min():>9.2f}"
f"{_pct(rets,1):>10.2f}{rets.min():>10.2f}")
def _load_policies():
"""Return {name: (cls, params)} for base, no_sl, close_confirm(buf0)."""
p22 = Path(__file__).resolve().parents[0] / "22_no_sl.py"
p16 = Path(__file__).resolve().parents[0] / "16_close_confirm_sl.py"
import importlib.util
def _load(path, attr):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return getattr(mod, attr)
NoSl = _load(p22, "NoSl")
CloseConfirm = _load(p16, "CloseConfirmSl")
return {
"base": (ExitPolicy, {}),
"no_sl": (NoSl, {"mode": "none"}),
"close_confirm0": (CloseConfirm, {"buffer": 0.0}),
}
# --------------------------------------------------------------------------- (2) crashes
CRASHES = {
"2020-03-12 covid": ("2020-03-10", "2020-03-16"),
"2021-05-19 leverage": ("2021-05-17", "2021-05-23"),
"2022-06 3AC/Luna": ("2022-06-10", "2022-06-20"),
"2022-11 FTX": ("2022-11-07", "2022-11-12"),
}
def _ms(s):
return int(pd.Timestamp(s, tz="UTC").value // 1e6)
def section2():
print("\n" + "=" * 100)
print("(2) CRASH WINDOWS — trades OPEN (entry inside window) per policy: realized "
"loss (ret%) and MAE%.")
print("=" * 100)
policies = _load_policies()
for label, (a, b) in CRASHES.items():
lo, hi = _ms(a), _ms(b)
print(f"\n### {label} [{a} .. {b}]")
any_trade = False
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
for pname, (cls, prm) in policies.items():
tr = _replay_trades(cls, sleeve, prm)
win = [t for t in tr if lo <= t["ts_entry"] <= hi]
if not win:
continue
any_trade = True
rets = np.array([t["ret"] for t in win]) * 100
mae = np.array([t["mae"] for t in win]) * 100
worst = min(win, key=lambda t: t["ret"])
print(f" {key:<11}{pname:<16}n{len(win):>3} "
f"sumRet{rets.sum():>8.2f}% worstRet{rets.min():>8.2f}% "
f"worstMAE{mae.min():>8.2f}% avgBars{np.mean([t['bars'] for t in win]):>5.1f}")
if not any_trade:
print(" (no trades opened in window across sleeves)")
# --------------------------------------------------------------------------- (3) -2% fallback
def section3():
print("\n" + "=" * 100)
print("(3) LIVE -2% FALLBACK on no_sl. NOTE: with no_sl the worker has NO stop at "
"all (branch analysis in module docstring).")
print(" Below = what a -2% PRICE stop (==-6% ret at lev3) WOULD cap if it WERE "
"wired. Compares no_sl ret vs a synthetic no_sl+2%stop.")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
stop_ret = -0.02 * LEV # -2% price move * leverage = -6% on notional in ret units
hdr = f"{'sleeve':<11}{'no_sl ret%':>12}{'+2%stop ret%':>14}{'Δret pp':>10}{'n capped':>10}{'worst no_sl':>13}{'worst +2%':>11}"
print(hdr)
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
tr = _replay_trades(NoSl, sleeve, {"mode": "none"})
# synthetic: if MAE breaches stop_ret, realize at stop_ret (approx; ignores
# that price may recover — that's the point: a -2% stop locks the loss).
base_rets = np.array([t["ret"] for t in tr])
capped = []
n_cap = 0
fee = EL.FEE_RT * LEV
for t in tr:
if t["mae"] <= stop_ret:
capped.append(stop_ret - fee) # stopped at -2% price, pay fee
n_cap += 1
else:
capped.append(t["ret"])
capped = np.array(capped)
def _compound(rets):
cap = 1000.0
for r in rets:
cap = max(cap + cap * EL.POS * r, 10.0)
return (cap / 1000.0 - 1) * 100
r_nosl = _compound(base_rets)
r_stop = _compound(capped)
print(f"{key:<11}{r_nosl:>12.0f}{r_stop:>14.0f}{r_stop - r_nosl:>10.1f}"
f"{n_cap:>10}{base_rets.min()*100:>13.2f}{capped.min()*100:>11.2f}")
# --------------------------------------------------------------------------- (4) disaster SL
class DisasterSl(ExitPolicy):
"""no_sl behaviour + a FAR intrabar disaster stop at `mult` x base SL distance."""
name = "disaster_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
mult = float(params.get("mult", 3.0))
self.sl = entry + mult * (sl0 - entry)
def levels(self, j: int):
return self.tp0, self.sl, 1.0
def _full_metrics(cls, sleeve, prm):
full = simulate(cls, sleeve, prm)
oos = simulate(cls, sleeve, prm, start_ms=OOS_START_MS)
return full, oos
def section4():
print("\n" + "=" * 100)
print("(4) DISASTER SL — no_sl + far intrabar stop at 3x / 4x base SL distance. "
"Keep the gain, cut the tail?")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
hdr = (f"{'sleeve':<11}{'policy':<13}"
f"{'FULLret%':>10}{'FULLdd%':>9}{'FULLsh':>8}"
f"{'OOSret%':>9}{'OOSdd%':>8}{'OOSsh':>7}{'worstMAE%':>11}{'nStop':>7}")
print(hdr)
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
rows = [
("base", ExitPolicy, {}),
("no_sl", NoSl, {"mode": "none"}),
("disaster3x", DisasterSl, {"mult": 3.0}),
("disaster4x", DisasterSl, {"mult": 4.0}),
]
print(f"--- {key}")
for pname, cls, prm in rows:
full, oos = _full_metrics(cls, sleeve, prm)
tr = _replay_trades(cls, sleeve, prm)
mae = np.array([t["mae"] for t in tr]) * 100
# count trades that hit the disaster stop (ret near the stop level)
n_stop = 0
if pname.startswith("disaster"):
mult = prm["mult"]
for t, raw in zip(tr, sleeve["signals"]):
pass
# simpler: a stop hit shows up as a large negative ret roughly = stop level
n_stop = int(np.sum(mae <= -2.0 * mult * LEV / LEV * 0)) # placeholder
print(f"{'':<11}{pname:<13}"
f"{full.get('ret_pct',0):>10.0f}{full.get('dd_pct',0):>9.2f}{full.get('sharpe_t',0):>8.2f}"
f"{oos.get('ret_pct',0):>9.0f}{oos.get('dd_pct',0):>8.2f}{oos.get('sharpe_t',0):>7.2f}"
f"{mae.min():>11.2f}{'':>7}")
# --------------------------------------------------------------------------- aggregate
def section5_aggregate():
"""Equal-weight aggregate across the 6 sleeves: does no_sl's tail blow up the
PORTFOLIO worst-trade vs disaster3x? Sum of per-sleeve compounded won't aggregate
DD honestly, so we report the WORST single-trade ret across all sleeves and the
99th pct of the pooled trade distribution."""
print("\n" + "=" * 100)
print("(5) POOLED TRADE DISTRIBUTION across all 6 sleeves (the real tail metric).")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
cfgs = [
("base", ExitPolicy, {}),
("no_sl", NoSl, {"mode": "none"}),
("close_confirm0", _load_policies()["close_confirm0"][0], {"buffer": 0.0}),
("disaster3x", DisasterSl, {"mult": 3.0}),
("disaster4x", DisasterSl, {"mult": 4.0}),
]
print(f"{'policy':<16}{'n':>6}{'retP1%':>9}{'retMin%':>9}{'maeP1%':>9}{'maeMin%':>9}{'<-15%cnt':>10}")
for pname, cls, prm in cfgs:
allret, allmae = [], []
for (code, asset), sleeve in SLEEVES:
tr = _replay_trades(cls, sleeve, prm)
allret += [t["ret"] * 100 for t in tr]
allmae += [t["mae"] * 100 for t in tr]
ar = np.array(allret); am = np.array(allmae)
n_bad = int(np.sum(ar < -15.0))
print(f"{pname:<16}{len(ar):>6}{_pct(ar,1):>9.2f}{ar.min():>9.2f}"
f"{_pct(am,1):>9.2f}{am.min():>9.2f}{n_bad:>10}")
if __name__ == "__main__":
section1()
section2()
section3()
section4()
section5_aggregate()
+171
View File
@@ -0,0 +1,171 @@
"""Harness ONESTO condiviso per esplorare nuove famiglie di strategie.
Regole NON negoziabili (per non ripetere l'errore squeeze look-ahead):
- direzione e prezzo decisi con dati FINO a close[i] incluso, mai con la barra i
usata per scegliere la direzione e poi entrare a i-1;
- ingresso ESEGUIBILE a close[i];
- exit: take-profit / stop-loss intrabar (high/low) e/o time-limit max_bars;
tp/sl possono essere None -> exit solo a tempo (utile per stagionalita');
- una posizione per volta (non-overlap), capitale composto;
- NETTO dopo fee round-trip (default 0.10% RT reale Deribit) e leva;
- validazione OOS (held-out, ultimo 30%) + sweep fee 0.00-0.20% RT.
Le strategie ad alta frequenza muoiono di fee: ogni entry costa fee_rt*lev sul
notional. Tienine conto: meno operazioni e edge > costi.
Asset disponibili: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
"""
from __future__ import annotations
import sys
from pathlib import Path
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
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
LEV = 3.0
POS = 0.15
OOS_FRAC = 0.30
ASSETS = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
BARS_PER_YEAR = {"5m": 105120, "15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
# --------------------------- dati ---------------------------
def get_df(asset: str, tf: str) -> pd.DataFrame:
"""OHLCV con colonna dt (UTC). tf nativo (5m,15m,1h) o resample da 1h (4h,1d).
timestamp resta ms-epoch reale anche dopo il resample (no placeholder)."""
if tf in ("5m", "15m", "1h"):
df = load_data(asset, tf).reset_index(drop=True)
else:
base = load_data(asset, "1h").copy()
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
base = base.set_index("dt")
rule = {"4h": "4h", "1d": "1D"}[tf]
agg = base.resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
epoch = pd.Timestamp("1970-01-01", tz="UTC") # ms-epoch portabile (qualsiasi risoluzione)
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
df = agg.reset_index(drop=True)
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
return df
def _dt(df: pd.DataFrame) -> pd.DatetimeIndex:
return pd.to_datetime(df["timestamp"], unit="ms", utc=True)
# --------------------------- indicatori ---------------------------
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
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 ema(x: np.ndarray, n: int) -> np.ndarray:
return pd.Series(x).ewm(span=n, adjust=False).mean().values
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
d = np.diff(close, prepend=close[0])
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
rs = up / dn.replace(0, np.nan)
return (100 - 100 / (1 + rs)).values
# --------------------------- engine ---------------------------
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS, split: int = -1) -> dict:
"""entries: dict con i(idx), d(+1/-1), max_bars; tp/sl opzionali (None=solo tempo).
split: se >0, conta solo entries con i>=split (finestra OOS)."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
ts = _dt(df)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = 0
last_exit = -1
bars_in = 0
yearly: dict[int, float] = {}
rets: list[float] = []
for e in entries:
i, d = e["i"], e["d"]
if i <= last_exit or i + 1 >= n or i < split:
continue
entry = c[i]
tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
cb = cap
cap = max(cb + cb * pos * ret, 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; bars_in += (j - i)
last_exit = j
rets.append(ret * pos)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
return {
"trades": trades,
"win": wins / trades * 100 if trades else 0.0,
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"sharpe": sharpe,
"yearly": yearly,
"exposure": bars_in / n * 100 if n else 0.0,
}
def evaluate(name: str, entries: list[dict], df: pd.DataFrame,
fees=(0.0, 0.0005, 0.001, 0.002)) -> dict:
"""Valuta una lista di entries: FULL, OOS e sweep fee. Stampa una riga sintetica."""
split = int(len(df) * (1 - OOS_FRAC))
full = simulate(entries, df)
oos = simulate(entries, df, split=split)
sweep = {f: simulate(entries, df, fee_rt=f)["ret"] for f in fees}
sweep_oos = {f: simulate(entries, df, fee_rt=f, split=split)["ret"] for f in fees}
yrs = full["yearly"]; pos_yrs = sum(1 for v in yrs.values() if v > 0)
print(f" {name:<24s} trd={full['trades']:>5d} win={full['win']:>4.1f}% "
f"FULL={full['ret']:>+7.0f}% OOS={oos['ret']:>+7.0f}% DD={full['dd']:>4.0f}% "
f"oDD={oos['dd']:>4.0f}% Shrp={full['sharpe']:>4.2f} exp={full['exposure']:>4.1f}% "
f"anniPos={pos_yrs}/{len(yrs)} | fee0.2%: FULL={sweep[0.002]:>+6.0f} OOS={sweep_oos[0.002]:>+6.0f}")
return {"full": full, "oos": oos, "sweep": sweep, "sweep_oos": sweep_oos, "pos_yrs": pos_yrs, "n_yrs": len(yrs)}
def robust(res: dict) -> bool:
"""Verdetto onesto: positivo FULL e OOS, regge a fee 0.20% RT, quasi tutti gli anni positivi."""
return (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0
and res["pos_yrs"] >= max(res["n_yrs"] - 1, 1))
if __name__ == "__main__":
# smoke test: una stagionalita' banale (hour-of-day) su BTC 1h
df = get_df("BTC", "1h"); ts = _dt(df)
ents = [{"i": i, "d": 1, "max_bars": 6, "tp": None, "sl": None}
for i in range(len(df) - 7) if ts.iloc[i].hour == 0]
print("smoke test — BTC long ad ogni 00:00 UTC, hold 6h:")
evaluate("seasonality_h0", ents, df)
+151
View File
@@ -0,0 +1,151 @@
"""GATE PORT06 — fade MR01/MR02/MR07 a 15m (origine: probe ACCEL50 2026-06-12).
Domanda onesta: i 6 sleeve fade girati a 15m (parametri live 1h NON ri-tunati,
trasferimento pre-registrato anti-overfit) MIGLIORANO il PORT06, o sono solo una
variante piu' veloce e correlata degli STESSI fade 1h?
Metodo (engine CANONICO build_trades/fade_daily_equity, NON le classi Strategy):
[1] PARITA': il builder locale a tf='1h' == sleeve canonico di build_everything.
[2] STANDALONE daily 1h vs 15m per twin (Sharpe/DD FULL e OOS su IDX comune)
+ stress fee 2x (0.20% RT) sul 15m (4x trade -> fee di prim'ordine).
[3] CORRELAZIONE daily 15m vs twin 1h: se ~1 e' ridondante (il pairs 15m
passo' a 0.37).
[4] GATE PORT06: baseline vs ADD (19+6 sleeve) vs SWAP (15m al posto del 1h)
vs BLEND (sleeve fade = 0.5*1h + 0.5*15m). Promosso se vs baseline l'OOS
Sharpe non peggiora E il DD scende (criterio standard dei gate).
uv run python scripts/analysis/fade15m_port06_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
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 src.portfolio import weighting as W
from scripts.analysis.combine_portfolio import (
IDX, SPLIT, OOS_DATE, _norm, metrics, port_returns,
)
from scripts.analysis.risk_management import strats_for, build_trades, INIT, POS
from scripts.analysis.report_families import build_everything
from scripts.portfolios._defs import PORTFOLIOS
FADE_IDS = [f"{nm}_{a}" for a in ("BTC", "ETH") for nm in ("MR01", "MR02", "MR07")]
def fade_daily_tf(asset: str, fn, params, tf: str, fee_rt: float = 0.001) -> pd.Series:
"""fade_daily_equity canonico, parametrizzato sul timeframe (stesso engine)."""
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
trades = build_trades(fn(df, **params), df, fee_rt=fee_rt, trend_max=3.0)
n = len(df)
eq = np.full(n, INIT, dtype=float)
cap = INIT
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * POS * ret, 10.0)
eq[j:] = cap
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
def build_fade15(fee_rt: float = 0.001) -> dict[str, pd.Series]:
out = {}
for asset in ("BTC", "ETH"):
for nm, (fn, params) in strats_for(asset).items():
out[f"{nm}_{asset}_15M"] = fade_daily_tf(asset, fn, params, "15m", fee_rt)
return out
def std(label: str, eq: pd.Series) -> str:
r = eq.pct_change().fillna(0.0)
f, o = metrics(r), metrics(r, lo=SPLIT)
return (f" {label:<16s} FULL ret{f['ret']:>+8.0f}% DD{f['dd']:>6.1f}% Sh{f['sharpe']:>6.2f}"
f" | OOS ret{o['ret']:>+7.0f}% DD{o['dd']:>5.1f}% Sh{o['sharpe']:>6.2f}")
def port_metrics(members: dict[str, pd.Series], p) -> tuple[dict, dict]:
ids = list(members)
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
clusters = {i: i for i in ids}
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
drp = port_returns(members, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def prow(label: str, fo: tuple[dict, dict]) -> str:
f, o = fo
return (f" {label:<22s} FULL CAGR{f['cagr']:>5.0f}% DD{f['dd']:>6.2f}% Sh{f['sharpe']:>6.2f}"
f" | OOS CAGR{o['cagr']:>5.0f}% DD{o['dd']:>6.2f}% Sh{o['sharpe']:>6.2f}")
def main() -> None:
print("=" * 100)
print(" GATE PORT06 — fade 15m (MR01/02/07 x BTC/ETH) vs 1h deployato")
print(f" Finestra comune {IDX[0].date()} -> {IDX[-1].date()}, OOS da {OOS_DATE}")
print("=" * 100)
print("\nCostruzione sleeve canonici (2-3 min)...")
S, pairs, tsm, shape = build_everything()
canon = {**S, **pairs, **tsm, **shape}
# [1] PARITA' del builder locale a 1h
print("\n[1] PARITA' builder locale 1h == canonico")
for sid in FADE_IDS:
nm, asset = sid.split("_")
fn, params = strats_for(asset)[nm]
mine = fade_daily_tf(asset, fn, params, "1h")
diff = float((mine - canon[sid]).abs().max())
print(f" {sid:<10s} max|diff| = {diff:.2e} {'OK' if diff < 1e-9 else 'VIOLAZIONE!'}")
# [2] STANDALONE 1h vs 15m + stress fee 2x sul 15m
print("\n[2] STANDALONE daily (engine canonico, pos 0.15 lev 3, fee 0.10% RT)")
fade15 = build_fade15()
fade15_fee2 = build_fade15(fee_rt=0.002)
for sid in FADE_IDS:
print(std(sid + " 1h", canon[sid]))
print(std(sid + " 15m", fade15[sid + "_15M"]))
print(std(sid + " 15m f2x", fade15_fee2[sid + "_15M"]))
# [3] CORRELAZIONE twin 15m vs 1h
print("\n[3] CORRELAZIONE daily 15m vs twin 1h (pairs 15m promosso a 0.37)")
cors = []
for sid in FADE_IDS:
c = canon[sid].pct_change().corr(fade15[sid + "_15M"].pct_change())
cors.append(c)
print(f" {sid:<10s} corr = {c:.2f}")
print(f" media = {np.mean(cors):.2f}")
# [4] GATE PORT06
print("\n[4] GATE PORT06 (weighting cap PAIRS 0.33 / SHAPE 0.0588)")
p = PORTFOLIOS["PORT06"]
base = {sid: canon[sid] for sid in p.sleeve_ids}
add = {**base, **fade15}
swap = dict(base)
blend = dict(base)
for sid in FADE_IDS:
e15 = fade15[sid + "_15M"]
swap[sid] = e15
rb = 0.5 * base[sid].pct_change().fillna(0.0) + 0.5 * e15.pct_change().fillna(0.0)
eq = (1 + rb).cumprod()
blend[sid] = eq / eq.iloc[0]
rows = {"BASELINE (1h)": port_metrics(base, p), "ADD (+6 sleeve 15m)": port_metrics(add, p),
"SWAP (15m al posto 1h)": port_metrics(swap, p), "BLEND 50/50": port_metrics(blend, p)}
for label, fo in rows.items():
print(prow(label, fo))
fb, ob = rows["BASELINE (1h)"]
print("\nVERDETTO (criterio: OOS Sharpe non peggiora E DD scende vs baseline):")
for label in ("ADD (+6 sleeve 15m)", "SWAP (15m al posto 1h)", "BLEND 50/50"):
f, o = rows[label]
ok = o["sharpe"] >= ob["sharpe"] - 1e-9 and (o["dd"] < ob["dd"] or f["dd"] < fb["dd"])
print(f" {label:<22s} -> {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,66 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
import importlib
FEE=0.001; LEV=3
def load_strat(mod):
m=importlib.import_module(mod)
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__)
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
def replay(df, sigs):
h=df['high'].values; l=df['low'].values; c=df['close'].values
out=[]; last=-1
for s in sigs:
i=s.idx
if i<=last: continue
d=s.direction; tp=s.metadata['tp']; sl=s.metadata['sl']; mb=s.metadata['max_bars']
j=min(i+mb,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;break
if h[t]>=tp: exit_p=tp;j=t;break
else:
if h[t]>=sl: exit_p=sl;j=t;break
if l[t]<=tp: exit_p=tp;j=t;break
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
out.append((i,ret)); last=j
return out
# raccogli tutti i trade con il loro dvol_pct e hurst all'ingresso
rows=[]
for asset in ("BTC","ETH"):
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
for code,(mod,par) in STR.items():
s=load_strat(mod); sigs=s.generate_signals(df,ts,**par)
for i,ret in replay(df,sigs):
rows.append(dict(asset=asset,code=code,year=ts.iloc[i].year,ret=ret,
dvol_pct=df['dvol_pct'].iloc[i], hurst=df['hurst'].iloc[i],
dvol=df['dvol'].iloc[i]))
R=pd.DataFrame(rows).dropna(subset=['dvol_pct'])
print(f"trade totali (con DVOL, 2021+): {len(R)}")
print("\n=== PnL medio per trade per TERZILE DVOL (bassa/media/alta vol) ===")
R['dvbin']=pd.cut(R['dvol_pct'],[0,.33,.66,1.0],labels=['LOW-vol','MID','HIGH-vol'])
g=R.groupby('dvbin',observed=True)['ret']
print(f" {'regime':<10}{'n':>6}{'ret_medio%':>12}{'win%':>8}{'somma%':>10}")
for b in ['LOW-vol','MID','HIGH-vol']:
x=R[R.dvbin==b]['ret']
print(f" {b:<10}{len(x):>6}{x.mean()*100:>12.3f}{(x>0).mean()*100:>8.1f}{x.sum()*100:>10.0f}")
print("\n=== dentro LOW-vol: split per HURST (anti-persistente vs trending) ===")
LV=R[R.dvbin=='LOW-vol'].copy()
LV['hbin']=pd.cut(LV['hurst'],[0,.45,.55,1.0],labels=['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)'])
for b in ['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)']:
x=LV[LV.hbin==b]['ret']
if len(x): print(f" {b:<24}{len(x):>6} ret_medio {x.mean()*100:>+7.3f}% win {(x>0).mean()*100:>5.1f}% somma {x.sum()*100:>+6.0f}%")
print("\n=== per anno: PnL fade in LOW-vol vs resto ===")
for y in range(2021,2027):
lo=R[(R.year==y)&(R.dvbin=='LOW-vol')]['ret']; hi=R[(R.year==y)&(R.dvbin!='LOW-vol')]['ret']
print(f" {y}: LOW-vol somma {lo.sum()*100:>+6.0f}% (n{len(lo)}) | MID/HIGH somma {hi.sum()*100:>+6.0f}% (n{len(hi)})")
@@ -0,0 +1,50 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
import importlib
FEE=0.001; LEV=3
def load_strat(mod):
m=importlib.import_module(mod)
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__)
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
def replay(df,sigs):
h=df['high'].values;l=df['low'].values;c=df['close'].values;out=[];last=-1
for s in sigs:
i=s.idx
if i<=last: continue
d=s.direction;tp=s.metadata['tp'];sl=s.metadata['sl'];mb=s.metadata['max_bars'];j=min(i+mb,len(c)-1);exit_p=c[j];reason='time'
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;reason='sl';break
if h[t]>=tp: exit_p=tp;j=t;reason='tp';break
else:
if h[t]>=sl: exit_p=sl;j=t;reason='sl';break
if l[t]<=tp: exit_p=tp;j=t;reason='tp';break
out.append((i,(exit_p-c[i])/c[i]*d*LEV-FEE*LEV,reason));last=j
return out
rows=[]
for asset in ("BTC","ETH"):
df=load_features(asset,"1h");ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
for code,(mod,par) in STR.items():
s=load_strat(mod)
for i,ret,reason in replay(df,s.generate_signals(df,ts,**par)):
rows.append(dict(ret=ret,reason=reason,dvol_pct=df['dvol_pct'].iloc[i],hurst=df['hurst'].iloc[i],
vratio=df['vratio'].iloc[i],higuchi=df['higuchi'].iloc[i]))
R=pd.DataFrame(rows).dropna(subset=['dvol_pct','hurst'])
L=R[R.ret<0] # solo i trade in perdita
print(f"trade {len(R)} | in perdita {len(L)} ({len(L)/len(R)*100:.0f}%) | somma perdite {L.ret.sum()*100:.0f}% | media perdita {L.ret.mean()*100:.2f}%")
print("\n=== somma PERDITE per regime (dove si concentra il danno) ===")
R['dvbin']=pd.cut(R.dvol_pct,[0,.33,.66,1],labels=['LOWvol','MID','HIGHvol'])
R['hbin']=pd.cut(R.hurst,[0,.45,.55,1],labels=['anti<.45','.45-.55','trend>.55'])
piv=R[R.ret<0].pivot_table(index='dvbin',columns='hbin',values='ret',aggfunc='sum',observed=True)*100
print((piv.round(0)).to_string())
print("\n (numeri = somma % delle perdite per cella; piu negativo = piu danno)")
print("\n=== quota di SL (stop) per regime ===")
slr=R.groupby(['dvbin','hbin'],observed=True).apply(lambda x:(x.reason=='sl').mean()*100, include_groups=False)
print(slr.round(0).to_string())
# worst tail
print(f"\n=== peggiori 1% trade: dove? ===")
W=R.nsmallest(max(10,len(R)//100),'ret')
print(f" worst {len(W)} trade: dvol_pct medio {W.dvol_pct.mean():.2f}, hurst medio {W.hurst.mean():.2f}, quota hurst>.55 {(W.hurst>.55).mean()*100:.0f}%, quota dvol<.33 {(W.dvol_pct<.33).mean()*100:.0f}%")
@@ -0,0 +1,56 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd, importlib
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns, build_trades
from src.portfolio.sleeves import all_sleeve_equities
from scripts.analysis.regime_lab import load_features
def load_strat(mod):
m=importlib.import_module(mod)
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__)
FADES={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
FEE=0.001; LEV=3; POS=0.15
def fade_equity_filtered(code, asset, hurst_thr=None):
"""equity giornaliera dello sleeve fade, opz. filtrata Hurst<thr (skip hurst>=thr). Convenzione fade_daily_equity."""
mod,par=FADES[code]; s=load_strat(mod)
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
h=df['high'].values; l=df['low'].values; c=df['close'].values; hur=df['hurst'].values
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
for sg in s.generate_signals(df,ts,**par):
i=sg.idx
if i<=last: continue
if hurst_thr is not None and not np.isnan(hur[i]) and hur[i]>=hurst_thr: continue # FILTRO
d=sg.direction; tp=sg.metadata['tp']; sl=sg.metadata['sl']; mb=sg.metadata['max_bars']
j=min(i+mb,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;break
if h[t]>=tp: exit_p=tp;j=t;break
else:
if h[t]>=sl: exit_p=sl;j=t;break
if l[t]<=tp: exit_p=tp;j=t;break
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
sser=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(sser)
base=all_sleeve_equities()
fade_ids=["MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH"]
def port(members):
dr=port_returns(members); return metrics(dr), metrics(dr,lo=SPLIT)
# baseline PORT06
fB,oB=port(base)
print(f"PORT06 baseline (17 sleeve): FULL Sharpe {fB['sharpe']:.2f} DD {fB['dd']:.2f}% | OOS Sharpe {oB['sharpe']:.2f} DD {oB['dd']:.2f}% ret {oB['ret']:+.0f}%")
# sostituisci le 6 fade con versione Hurst-skip
for thr in (0.55, 0.50):
filt=dict(base)
for fid in fade_ids:
code,asset=fid.split("_")
filt[fid]=fade_equity_filtered(code,asset,hurst_thr=thr)
fF,oF=port(filt)
print(f"PORT06 + Hurst-skip h<{thr} sulle fade: FULL Sharpe {fF['sharpe']:.2f} DD {fF['dd']:.2f}% | OOS Sharpe {oF['sharpe']:.2f} DD {oF['dd']:.2f}% ret {oF['ret']:+.0f}%")
@@ -0,0 +1,120 @@
export const meta = {
name: 'fade-lossguard',
description: 'Sistema anti-perdite per le fade in regime trending/low-vol: test meccanismi su MR01/02/07',
phases: [
{ title: 'Test', detail: 'agenti: ogni meccanismo di filtro applicato alle fade reali (BTC+ETH)' },
{ title: 'Synth', detail: 'classifica + miglior loss-guard, gate: riduce DD senza uccidere edge' },
],
}
const API = `
=== Harness (gia pronto) ===
import sys; sys.path.insert(0,'.')
import numpy as np, pandas as pd, importlib
from scripts.analysis.regime_lab import load_features, report
from scripts.analysis.explore_lab import robust, atr
def load_strat(mod):
m=importlib.import_module(mod)
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__)
FADES={'MR01':('scripts.strategies.MR01_bollinger_fade',dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
'MR02':('scripts.strategies.MR02_donchian_fade',dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
'MR07':('scripts.strategies.MR07_return_reversal',dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
# colonne regime_lab (causali): dvol, dvol_pct, vrp, funding_z, dvol_chg, hurst, higuchi, vratio, frac_up/dn
# ADX (se ti serve) calcolalo causale da OHLC; efficiency-ratio Kaufman = |c[i]-c[i-n]| / sum|diff| su [i-n,i].
# PATTERN: genera i segnali fade, poi APPLICA IL TUO FILTRO scartando le entries in regime sfavorevole,
# confronta BASELINE vs FILTRATA su ogni fade x asset:
def entries_from(strat, df, par, keep=lambda df,i: True):
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
out=[]
for s in strat.generate_signals(df,ts,**par):
if keep(df, s.idx): # keep=False -> filtro scarta (loss-guard)
out.append({'i':s.idx,'d':s.direction,'tp':s.metadata['tp'],'sl':s.metadata['sl'],'max_bars':s.metadata['max_bars']})
return out
# per ogni (fade,asset): res_base=report(.., entries_from(..., keep=tutto)); res_filt=report(.., col tuo keep)
# confronta: Sharpe OOS, DD full/oos, ret, #trade (quanti scartati), e robust(). Aggrega sulle 6 combo.
`
const CONTEXT = `
PROBLEMA: le fade (MR01 Bollinger, MR02 Donchian, MR07 return-reversal) sono mean-reversion 1h con
filtro trend EMA200 (trend_max=3.0). DIAGNOSI EMPIRICA (3022 trade, 2021+): le PERDITE e gli STOP
si concentrano nel regime PERSISTENTE/TRENDING, NON nella bassa vol:
- somma perdite per cella (Hurst x DVOL): la cella peggiore e' hurst>0.55 (-2695% in low-vol,
dominante in ogni terzile vol). I peggiori 1% trade hanno hurst medio 0.61 (77% con hurst>0.55).
- tasso STOP-LOSS: 43% quando hurst>0.55 vs 21% quando hurst<0.45 (anti-persistente). 2x.
- net: le celle restano positive (i winner battono), quindi filtrare toglie anche winner -> il
loss-guard e' utile SOLO se riduce DD/coda SENZA uccidere l'edge netto.
RICERCA ESTERNA (confermata): (a) Hurst regime filter: MR solo H<0.45, in 0.45-0.55 ridurre size,
evitare H>0.55. (b) ADX: MR profit factor 1.62 con ADX<20 vs -0.74 con ADX>30 (switch di regime piu'
importante). (c) ATR/vol-EXPANSION ratio>1.5 disabilita MR -> ha prevenuto il 72% delle perdite
maggiori. (d) time-stop: se non rientra in ~15 barre e' un trend, esci.
OBIETTIVO: trovare il MIGLIOR meccanismo (o combo) che, applicato alle fade reali, RIDUCE DD/coda/
stop-rate MANTENENDO l'edge netto OOS. Metodologia: causale no-look-ahead (le colonne regime_lab
sono causali; i filtri usano solo dati <= i), netto fee (report() fa OOS+sweep). LEZIONE FR01: un
filtro che riduce le perdite ma anche i winner spesso NON migliora -> il gate vero e' DD giu' a
parita' (o quasi) di Sharpe/ret, idealmente Sharpe SU e DD GIU'.
` + API
const SCHEMA = {
type: 'object',
properties: {
meccanismo: { type: 'string' },
descrizione: { type: 'string' },
base_oos_sharpe: { type: 'number' }, filt_oos_sharpe: { type: 'number' },
base_dd_full: { type: 'number' }, filt_dd_full: { type: 'number' },
base_oos_ret: { type: 'number' }, filt_oos_ret: { type: 'number' },
trade_scartati_pct: { type: 'number' },
riduce_perdite: { type: 'boolean', description: 'riduce DD/coda/stop-rate' },
preserva_edge: { type: 'boolean', description: 'edge netto OOS preservato (Sharpe non crolla, robust resta)' },
buon_lossguard: { type: 'boolean', description: 'riduce perdite SENZA uccidere edge -> candidato' },
verdetto: { type: 'string', description: 'numeri base vs filtrato aggregati sulle 6 combo fade x asset' },
},
required: ['meccanismo', 'buon_lossguard', 'riduce_perdite', 'preserva_edge', 'verdetto'],
}
const MECHS = [
['Hurst-skip H>0.55', 'scarta le fade quando rolling-hurst(window=100) >= 0.55 (regime persistente). Test anche soglia 0.50 e 0.60, riporta la migliore.'],
['Hurst-size transition', 'NON scartare ma RIDURRE: tieni tutte le entries ma pesa size 1.0 se hurst<0.45, 0.5 se 0.45-0.55, 0.25 se >0.55. (Per testare la riduzione size col report attuale: approssima scartando il 50%/75% delle entries nelle bin alte in modo deterministico per indice, oppure confronta solo le bin.)'],
['ADX-skip', 'calcola ADX(14) causale; scarta le fade quando ADX>25 (trend). Test soglie 20/25/30.'],
['vol-expansion vratio', 'scarta le fade quando vratio (vol breve/lunga, colonna regime_lab) > 1.5 (vol in espansione = breakout, non range). Test 1.3/1.5/1.8. (la ricerca dice -72% perdite maggiori)'],
['efficiency-ratio Kaufman', 'ER = |c[i]-c[i-n]|/sum(|diff|) su finestra n=20; scarta quando ER>0.5 (moto efficiente/trending). Test 0.4/0.5/0.6.'],
['time-stop piu corto', 'riduci max_bars da 24 a 12 o 15 (esci prima se non rientra = probabile trend). Confronta DD/edge.'],
['Hurst + vol-expansion combo', 'scarta se hurst>0.55 OPPURE vratio>1.5. Verifica se la combo riduce piu DD del singolo senza perdere piu edge.'],
['Hurst + ADX combo', 'scarta se hurst>0.55 E ADX>25 (doppia conferma di trend) -> piu selettivo, scarta meno winner.'],
['vol-target sizing', 'scala la size per 1/realized_vol (target vol costante): approssima tenendo solo le entries in vol moderata, riporta effetto su DD/coda.'],
['DVOL-rising skip', 'scarta le fade quando dvol_chg>0 forte (DVOL in salita = stress/espansione vol imminente). Test soglie su dvol_chg.'],
]
const ASSETS_NOTE = 'Applica a tutte e 3 le fade (MR01,MR02,MR07) su BTC E ETH (6 combo), aggrega base vs filtrato.'
phase('Test')
// ogni meccanismo = 1 agente che testa su tutte le 6 combo; piu' 4 agenti che esplorano combo/parametri fini
const tasks = MECHS.map(([nm, desc]) => () => agent(
CONTEXT + `\n\nMECCANISMO DA TESTARE: ${nm}\n${desc}\n\n${ASSETS_NOTE}\n` +
`Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && uv run python /tmp/<file>.py), confronta ` +
`BASELINE (fade senza filtro) vs FILTRATA su ogni combo, AGGREGA (media o somma equity) e riporta. ` +
`Il filtro deve essere CAUSALE. Decidi buon_lossguard=true SOLO se riduce DD/coda/stop-rate MANTENENDO ` +
`l'edge netto OOS (Sharpe non crolla, ret OOS resta ampiamente positivo). Cita i numeri base vs filtrato.`,
{ label: `mech:${nm.slice(0, 18)}`, phase: 'Test', schema: SCHEMA }))
const results = (await parallel(tasks)).filter(Boolean)
phase('Synth')
const good = results.filter(r => r.buon_lossguard)
const synthesis = await agent(
CONTEXT +
`\n\nRisultati di ${results.length} meccanismi testati:\n${JSON.stringify(results, null, 1)}\n\n` +
`SINTESI FINALE (italiano) per il decisore:
1) Esiste un loss-guard che riduce le perdite/DD delle fade in regime trending SENZA uccidere l'edge?
2) Tabella: meccanismo | base vs filtrato (OOS Sharpe, DD, ret, %trade scartati) | buon_lossguard?
3) Il MIGLIORE (e l'eventuale combo) con i numeri. Quanto DD/coda si risparmia e a che costo di ret.
4) Coerenza con la ricerca esterna (Hurst<0.45 / ADX / vol-expansion / time-stop).
5) Raccomandazione: quale filtro applicare alle fade live, con che soglia, e il caveat (serve feed
DVOL/regime live? il filtro va validato a livello PORT06 = riduce il DD del portafoglio?).
Onesta: se nessuno migliora davvero (riduce solo ret), dillo. Cita NUMERI reali.`,
{ label: 'synth-lossguard', phase: 'Synth' })
return { results, good, synthesis }
+160
View File
@@ -0,0 +1,160 @@
"""FADE TF SWEEP — i fade MR01/02/07 su 1m/2m/5m/10m/30m (oltre a 15m live e 1h).
Ricerca 2026-06-12 (post-swap 15m). Diario: docs/diary/2026-06-12-fade-tf-sweep.md.
Due banchi di prova:
A. STORIA COMPLETA (parquet locale; 10m = resample dal 5m): engine canonico
build_trades, daily equity su IDX comune, OOS da 2024-10, fee 0.10% e 2x.
B. FINESTRA COMUNE RECENTE (2026-02-12 -> 06-12): 1m/2m (fetch Cerbero, non
esiste storia locale 1m: il refresh la esclude per costo) vs 5m/15m sugli
STESSI 120 giorni — confronto apples-to-apples sul regime corrente.
Esiti:
- La frontiera Sharpe e' MONOTONA al scendere del tf per MR01/MR07 (full
history OOS: 5m > 10m > 15m > 30m > 1h)... ma il margine fee si assottiglia
insieme: a fee 2x MR02_BTC muore a 5m (-1.70) e resta fragile a 10m (0.32).
- MR02 (donchian, 3-6x i trade degli altri) sotto i 15m muore di fee nel
regime corrente: 1m -64%, 2m -44%, 5m -22% sulla finestra recente.
- 1m/2m: SCARTATI. MR01 a 1m brilla sulla finestra recente (ETH +60%, Sh 5.7)
ma muore a fee 2x, il flat-share 1m e' alto (ETH 25.6%, BTC 13.3% -> rischio
stale-print) e la validazione full-history e' impraticabile (storia 1m non
mantenuta). Il regime recente e' CALMO: anche il 5m vi e' fiacco — i tf
veloci pagano nella volatilita', non nella calma.
- 10m: il miglior candidato OLTRE il 15m (quasi l'edge del 5m con piu' margine
fee; corr daily col 15m live 0.53 media). Eventuale ADD da gateare in
futuro, NON ora: il 15m e' appena andato live (v1.1.30), un cambio alla volta.
- VERDETTO: tenere il 15m (ginocchio della frontiera margine-fee/rendimento);
10m in watchlist; 1m/2m chiusi; 5m no-swap (fee-fragile su MR02_BTC).
uv run python scripts/analysis/fade_tf_sweep.py
"""
from __future__ import annotations
import sys
from pathlib import Path
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.analysis.risk_management import strats_for, build_trades, INIT, POS
from scripts.analysis.combine_portfolio import IDX, SPLIT, _norm, metrics
EPOCH = pd.Timestamp(0, tz="UTC")
WINDOW_START = "2026-02-12" # finestra comune del banco B
RECENT_1M = {a: Path(f"/tmp/{a.lower()}_1m_recent.parquet") for a in ("BTC", "ETH")}
def resample_ohlcv(df: pd.DataFrame, minutes: int) -> pd.DataFrame:
"""Resample OHLCV unit-safe (pandas 2.x conserva datetime64[ms]: niente
aritmetica diretta su .view int64 — il //10**6 doppio manda i ts nel 1970)."""
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
g = df.set_index(ts).resample(f"{minutes}min")
out = pd.DataFrame({"open": g["open"].first(), "high": g["high"].max(),
"low": g["low"].min(), "close": g["close"].last(),
"volume": g["volume"].sum()}).dropna()
out["timestamp"] = (out.index - EPOCH) // pd.Timedelta(milliseconds=1)
return out.reset_index(drop=True)
def daily_eq(df: pd.DataFrame, fn, params, fee_rt: float = 0.001) -> tuple[pd.Series, int]:
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
trades = build_trades(fn(df, **params), df, fee_rt=fee_rt, trend_max=3.0)
n = len(df)
eq = np.full(n, INIT)
cap = INIT
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * POS * ret, 10.0)
eq[j:] = cap
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s), len(trades)
def full_history() -> None:
print("=== A. STORIA COMPLETA (OOS da 2024-10, fee 0.10% RT; f2x = OOS Sharpe a fee 2x) ===")
print(f"{'tf':<5} {'sleeve':<10} {'FULL%':>10} {'DD%':>6} {'Sh':>6} | {'OOS%':>8} {'oDD%':>6} {'oSh':>6} | {'f2x_oSh':>7} {'n':>6}")
for tf in ("5m", "10m", "15m", "30m"):
for asset in ("BTC", "ETH"):
df = resample_ohlcv(load_data(asset, "5m"), 10) if tf == "10m" else load_data(asset, tf)
for nm, (fn, params) in strats_for(asset).items():
eq, n = daily_eq(df, fn, params)
r = eq.pct_change().fillna(0.0)
f, o = metrics(r), metrics(r, lo=SPLIT)
eq2, _ = daily_eq(df, fn, params, fee_rt=0.002)
o2 = metrics(eq2.pct_change().fillna(0.0), lo=SPLIT)
print(f"{tf:<5} {nm + '_' + asset:<10} {f['ret']:>10.0f} {f['dd']:>6.1f} {f['sharpe']:>6.2f}"
f" | {o['ret']:>8.0f} {o['dd']:>6.1f} {o['sharpe']:>6.2f} | {o2['sharpe']:>7.2f} {n:>6}")
print()
def trade_stats(df: pd.DataFrame, fn, params, fee_rt: float = 0.001) -> dict:
trades = build_trades(fn(df, **params), df, fee_rt=fee_rt, trend_max=3.0)
cap = peak = INIT
dd = 0.0
rets = []
wins = 0
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * POS * ret, 10.0)
peak = max(peak, cap)
dd = max(dd, (peak - cap) / peak)
rets.append(ret * POS)
wins += ret > 0
n = len(trades)
sh = float(np.mean(rets) / np.std(rets) * np.sqrt(n)) if n > 1 and np.std(rets) > 0 else 0.0
return dict(ret=(cap / INIT - 1) * 100, dd=dd * 100, n=n,
wr=wins / n * 100 if n else 0.0, sh=sh)
def recent_window() -> None:
if not all(p.exists() for p in RECENT_1M.values()):
print("\n=== B. saltato: manca il parquet 1m recente (fetch Cerbero, vedi diario) ===")
return
start_ms = int(pd.Timestamp(WINDOW_START, tz="UTC").timestamp() * 1000)
data: dict[tuple[str, str], pd.DataFrame] = {}
for asset in ("BTC", "ETH"):
m1 = pd.read_parquet(RECENT_1M[asset]).sort_values("timestamp").reset_index(drop=True)
data[(asset, "1m")] = m1
data[(asset, "2m")] = resample_ohlcv(m1, 2)
for tf in ("5m", "15m"):
df = load_data(asset, tf)
data[(asset, tf)] = df[df["timestamp"] >= start_ms].reset_index(drop=True)
print(f"\n=== B. FINESTRA COMUNE {WINDOW_START} -> oggi (regime corrente) ===")
print("flat share (O=H=L=C):")
for (asset, tf), df in sorted(data.items()):
fl = ((df["open"] == df["high"]) & (df["high"] == df["low"]) & (df["low"] == df["close"])).mean() * 100
print(f" {asset} {tf:>3}: {fl:5.1f}%")
print(f"\n{'tf':<4} {'sleeve':<10} {'ret%':>8} {'DD%':>6} {'n':>5} {'WR%':>5} {'Sh_tr':>6} | {'fee2x_ret%':>10}")
for tf in ("1m", "2m", "5m", "15m"):
for asset in ("BTC", "ETH"):
for nm, (fn, params) in strats_for(asset).items():
r1 = trade_stats(data[(asset, tf)], fn, params)
r2 = trade_stats(data[(asset, tf)], fn, params, fee_rt=0.002)
print(f"{tf:<4} {nm + '_' + asset:<10} {r1['ret']:>8.1f} {r1['dd']:>6.1f} {r1['n']:>5}"
f" {r1['wr']:>5.1f} {r1['sh']:>6.2f} | {r2['ret']:>10.1f}")
print()
def correlations() -> None:
print("=== C. corr daily (storia completa) vs twin 15m LIVE ===")
c5s, c10s = [], []
for asset in ("BTC", "ETH"):
d15, d5 = load_data(asset, "15m"), load_data(asset, "5m")
d10 = resample_ohlcv(d5, 10)
for nm, (fn, params) in strats_for(asset).items():
e15 = daily_eq(d15, fn, params)[0].pct_change()
c5 = daily_eq(d5, fn, params)[0].pct_change().corr(e15)
c10 = daily_eq(d10, fn, params)[0].pct_change().corr(e15)
c5s.append(c5)
c10s.append(c10)
print(f" {nm}_{asset:<4} 5m-15m {c5:.2f} 10m-15m {c10:.2f}")
print(f" media: 5m-15m {np.mean(c5s):.2f} | 10m-15m {np.mean(c10s):.2f}")
if __name__ == "__main__":
full_history()
recent_window()
correlations()
@@ -0,0 +1,54 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns
from src.portfolio.sleeves import all_sleeve_equities
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
FEE=0.001; LEV=3; POS=0.15
def fr01_daily_equity(asset):
df=load_features(asset,"1h")
c=df['close'].values; h=df['high'].values; l=df['low'].values; a=atr(df,14)
ma=pd.Series(c).rolling(50).mean().values; sd=pd.Series(c).rolling(50).std().values
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
for i in range(64,len(c)-1):
if i<=last or np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
if df['hurst'].iloc[i]>=0.55: continue
if np.isnan(df['dvol_pct'].iloc[i]) or df['dvol_pct'].iloc[i]>=0.40: continue
if c[i]<ma[i]-2.5*sd[i]: d,sl,tp=1,c[i]-2*a[i],ma[i]
elif c[i]>ma[i]+2.5*sd[i]: d,sl,tp=-1,c[i]+2*a[i],ma[i]
else: continue
j=min(i+24,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl; j=t; break
if h[t]>=tp: exit_p=tp; j=t; break
else:
if h[t]>=sl: exit_p=sl; j=t; break
if l[t]<=tp: exit_p=tp; j=t; break
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
s=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
members=all_sleeve_equities()
print(f"PORT06 sleeve: {len(members)} | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da idx {SPLIT} ({IDX[SPLIT].date()})")
fr={"FR01_BTC":fr01_daily_equity("BTC"), "FR01_ETH":fr01_daily_equity("ETH")}
base=port_returns(members) # equal-weight 17 sleeve (metro combine)
aug =port_returns({**members,**fr}) # + FR01x2 (19 sleeve)
def show(tag, dr):
f=metrics(dr); o=metrics(dr,lo=SPLIT)
print(f" {tag:<22} FULL: Sharpe {f['sharpe']:.2f} DD {f['dd']:.1f}% ret {f['ret']:+.0f}% | OOS: Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
print("\n=== MASTER equal-weight: con/senza FR01 ===")
show("PORT06 (17 sleeve)", base)
show("PORT06 + FR01 (19)", aug)
# correlazione FR01 vs portafoglio MASTER aggregato + standalone
for k,e in fr.items():
r=e.pct_change().fillna(0.0); corr=np.corrcoef(r, base)[0,1]
f=metrics(r); o=metrics(r,lo=SPLIT)
print(f"\n {k}: corr vs MASTER = {corr:+.3f} | standalone OOS Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
@@ -0,0 +1,86 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
FEE=0.001; LEV=3
def build(df, gate, k=2.5, sl_atr=2.0, mb=24, bb=50):
c=df['close'].values; a=atr(df,14)
ma=pd.Series(c).rolling(bb).mean().values; sd=pd.Series(c).rolling(bb).std().values
ent=[]
for i in range(bb+14,len(c)-1):
if np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
if not gate(df,i): continue
if c[i]<ma[i]-k*sd[i]: d,sl=1,c[i]-sl_atr*a[i]
elif c[i]>ma[i]+k*sd[i]: d,sl=-1,c[i]+sl_atr*a[i]
else: continue
ent.append({'i':i,'d':d,'tp':ma[i],'sl':sl,'mb':mb})
return ent
def per_year(df, ent):
"""replay intrabar fedele (sl-first, tp, poi max_bars@close) -> per anno {n,ret%,win%}."""
h=df['high'].values; l=df['low'].values; c=df['close'].values
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
Y={}
last=-1
for e in ent:
i=e['i']
if i<=last: continue
d=e['d']; tp=e['tp']; sl=e['sl']; j=min(i+e['mb'],len(c)-1)
exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl; j=t; break
if h[t]>=tp: exit_p=tp; j=t; break
else:
if h[t]>=sl: exit_p=sl; j=t; break
if l[t]<=tp: exit_p=tp; j=t; break
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
last=j; yr=ts.iloc[i].year
if yr not in Y: Y[yr]=[0,0.0,0]
Y[yr][0]+=1; Y[yr][1]+=ret*100; Y[yr][2]+= (ret>0)
return Y
# gate functions
def g_hurst_calm(df,i): return df['hurst'].iloc[i]<0.55 and not np.isnan(df['dvol_pct'].iloc[i]) and df['dvol_pct'].iloc[i]<0.40
def g_vrp_neg(df,i): return not np.isnan(df['vrp'].iloc[i]) and df['vrp'].iloc[i]<0
def g_hig_vrp(df,i):
hi=df['higuchi'].iloc[i]; return (not np.isnan(hi)) and hi>1.5 and (not np.isnan(df['vrp'].iloc[i])) and df['vrp'].iloc[i]<0
def g_none(df,i): return True
STRATS=[("HurstCalmFade (hurst<.55 & DVOL<p40)",g_hurst_calm),
("VRP<0 Fade (core driver)",g_vrp_neg),
("HigVRP Fade (Higuchi>1.5 & VRP<0)",g_hig_vrp),
("Fade NUDA (no gate, baseline)",g_none)]
# regime mercato BTC per anno (da BTC close annuale)
btc=load_features("BTC","1d")
bts=pd.to_datetime(btc['timestamp'],unit='ms',utc=True); bc=btc['close'].values
mkt={}
for yr in range(2021,2027):
m=(bts.dt.year==yr).values
if m.sum()>5:
r=(bc[m][-1]/bc[m][0]-1)*100
mkt[yr]=("BULL" if r>40 else "BEAR" if r<-30 else "RANGE", r)
for asset in ("BTC","ETH"):
df=load_features(asset,"1h")
print(f"\n{'='*78}\n {asset} 1h — performance per anno (somma ret% per-trade, netto leva3x+fee0.10%)\n{'='*78}")
print(f" {'Strategia':<40} " + " ".join(f"{y}" for y in range(2021,2027)))
for name,g in STRATS:
ent=build(df,g); Y=per_year(df,ent)
cells=[]
for y in range(2021,2027):
if y in Y and Y[y][0]>0:
cells.append(f"{Y[y][1]:+5.0f}")
else: cells.append(" . ")
print(f" {name:<40} " + " ".join(cells))
# riga trades/anno per la strategia principale
ent=build(df,g_hurst_calm); Y=per_year(df,ent)
tr=" ".join(f"{Y.get(y,[0])[0]:>5}" for y in range(2021,2027))
print(f" {'(HurstCalmFade trades/anno)':<40} {tr}")
print(f"\n REGIME MERCATO BTC per anno (ret% annuale prezzo):")
for y in range(2021,2027):
if y in mkt: print(f" {y}: {mkt[y][0]:6} ({mkt[y][1]:+.0f}%)")
@@ -0,0 +1,175 @@
export const meta = {
name: 'fractal-argo-search',
description: 'Ricerca a ~100 agenti: strategia FRATTALI del segnale x REGIME ARGO (DVOL/funding/VRP) validata OOS',
phases: [
{ title: 'Search', detail: '92 agenti: griglia frattale x regime x asset + wildcard' },
{ title: 'Verify', detail: 'verifica avversariale dei survivor (look-ahead, fee 0.2%, altro asset/split)' },
{ title: 'Synth', detail: 'classifica, sceglie vincitori, propone implementazione' },
],
}
const API = `
=== regime_lab API (gia pronta, dati FRESCHI in cache) ===
from scripts.analysis.regime_lab import load_features, report
from scripts.analysis.explore_lab import robust, atr, ema, rsi
df = load_features(ASSET, TF) # ASSET in {BTC,ETH}, TF in {1h,4h,1d}
# Colonne (tutte CAUSALI, valore a barra i usa solo dati <= i):
# OHLCV: open high low close volume timestamp
# REGIME (ARGO-proxy backtestabile): dvol, dvol_pct (percentile rolling 0..1),
# rv (realized vol ann.), vrp = dvol-rv (>0 = vol sopravvalutata ~ ARGO GEX+ range),
# funding, funding_z (z-score rolling), dvol_chg (DVOL salita/discesa, proxy term-structure)
# FRATTALI: hurst (>0.5 persistente/trend, <0.5 anti-persistente/mean-rev), higuchi (FD: alta=frastagliato),
# vratio (vol breve/lunga), frac_up/frac_dn (Williams pivot bool: swing high/low confermati, CAUSALI)
# NB: dvol e' NaN prima del 2021-03 (storico DVOL) -> salta le barre con dvol NaN se usi il regime.
# Costruisci 'entries': lista dict {i, d(+1/-1), tp, sl, max_bars}. INGRESSO ESEGUIBILE:
# i, d, tp, sl decisi con dati <= close[i]. tp/sl in PREZZO (o None). Esempio fade:
ent=[]
c=df['close'].values; a=atr(df,14); ma=df['close'].rolling(50).mean().values; sd=df['close'].rolling(50).std().values
for i in range(300, len(c)-1):
if np.isnan(sd[i]) or np.isnan(df['dvol_pct'].iloc[i]): continue
if df['vrp'].iloc[i] > 0 and c[i] < ma[i]-2.5*sd[i]: # GATE regime + SEGNALE frattale/tecnico
ent.append({'i':i,'d':1,'tp':ma[i],'sl':c[i]-2*a[i],'max_bars':24})
res = report('NOME', ent, df) # -> {full:{ret,sharpe,dd,trades,win,exposure}, oos:{...}, sweep, sweep_oos, pos_yrs, n_yrs}
ok = robust(res) # True = full+oos>0 E regge fee 0.2% RT E anni ~tutti positivi
print('ROBUST', ok, 'trd', res['full']['trades'], 'OOSsharpe', round(res['oos']['sharpe'],2),
'OOSret', round(res['oos']['ret']), 'fee02OOS', round(res['sweep_oos'][0.002]))
`
const CONTEXT = `
PROGETTO PythagorasGoal: trading crypto BTC/ETH. Edge dimostrato = SOLO mean-reversion (fade) + pairs.
ASTICELLA ALTA: il portafoglio PORT06 e' gia a Sharpe OOS 8.19 / DD 2.3%. Una strategia nuova vale solo
se ha edge NETTO validato OOS e robusto.
PRIORI ONESTI (non ignorarli): i FRATTALI sono stati gia esplorati e quasi tutti RUMORE (shape_lab:
analog kNN solo BTC-overfit; PIP/pivot 0/48 robuste; DTW peggiora). Le OPZIONI sono state SCARTATE
(W18/19/21 VRP). L'unico edge frattale validato e SH01 (shape-ML logit, diversificatore). MA: la
combinazione FRATTALE-del-segnale x REGIME-ARGO (gating su DVOL/funding/VRP) e' NUOVA e non testata ->
e' qui che potrebbe esserci valore: il regime puo dire QUANDO il segnale frattale funziona.
OBIETTIVO: trovare una strategia che combini un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME
(ARGO-proxy: DVOL percentile, VRP, funding) e che superi la validazione onesta (robust()=True).
METODOLOGIA OBBLIGATORIA: ingresso ESEGUIBILE senza look-ahead (le colonne regime_lab sono gia causali;
le TUE entries devono usare solo dati <= i). Backtest NETTO fee (report() fa gia sweep 0.0-0.2% RT + OOS
ultimo 30%). robust()=True e' il gate minimo. Diffida dell'overfit: poche entries o edge solo full e
non-oos = rumore. Riporta ONESTAMENTE anche i fallimenti.
` + API
const SCHEMA = {
type: 'object',
properties: {
strategy: { type: 'string', description: 'nome + 1 frase: segnale frattale + gate regime' },
family: { type: 'string' }, angle: { type: 'string' }, asset: { type: 'string' }, tf: { type: 'string' },
trades: { type: 'integer' },
full_ret: { type: 'number' }, oos_ret: { type: 'number' },
full_sharpe: { type: 'number' }, oos_sharpe: { type: 'number' }, oos_dd: { type: 'number' },
fee02_oos_ret: { type: 'number', description: 'OOS ret a fee 0.2% RT' },
robust: { type: 'boolean', description: 'robust()=True' },
promising: { type: 'boolean', description: 'vale una verifica avversariale (robust o quasi, non overfit)' },
edge_desc: { type: 'string', description: 'perche funziona / perche e rumore, con i numeri' },
},
required: ['strategy', 'asset', 'tf', 'trades', 'oos_sharpe', 'robust', 'promising', 'edge_desc'],
}
const FAMILIES = [
['hurst', 'Hurst regime: fade quando hurst<0.5 (anti-persistente), o trend quando hurst>0.5. Soglia hurst come segnale o gate.'],
['higuchi', 'Fractal dimension Higuchi: FD alta = frastagliato/range (fade), FD bassa = liscio/trend (momentum).'],
['williams', 'Williams pivot (frac_up/frac_dn, causali): fade del pivot (reversione allo swing) o breakout del pivot.'],
['vratio', 'volatility_ratio: >1 espansione vol (breakout/fade del breakout), <1 compressione (range/squeeze).'],
['analog', 'analog kNN sulla FORMA (puoi usare scripts.analysis.shape_lab.analog_signals(df,...)): forecast causale segno a H barre, gatealo col regime.'],
['multiscale', 'multi-scala: combina hurst+higuchi+vratio in un indice di "regime frattale" (trend vs chop) come segnale.'],
['candle', 'pattern candele frattali (src.fractal.patterns: extract_body_ratios/shadow, find_patterns): sequenze multi-barra come segnale.'],
]
const ANGLES = [
['none', 'NESSUN gate regime: segnale frattale puro (baseline per misurare il valore marginale del regime).'],
['dvol_high', 'agisci solo con dvol_pct alto (>0.6..0.8): vol elevata (spesso mean-reversion piu forte).'],
['dvol_low', 'agisci solo con dvol_pct basso (<0.3..0.4): calma/range.'],
['vrp', 'VRP=vrp colonna: VRP>0 (vol sopravvalutata, analogo ARGO GEX+ -> range/fade); confronta con VRP<0. Gate o peso.'],
['funding', 'funding_z estremo: troppi long (funding_z alto) -> fade ribassista; troppi short -> fade rialzista (flusso ARGO via perp).'],
['dvol_chg', 'dvol_chg: DVOL in salita (espansione vol/stress -> trend) vs discesa (ritorno calma -> range).'],
]
const ASSETS = ['BTC', 'ETH']
phase('Search')
// 7 famiglie x 6 angoli x 2 asset = 84 agenti griglia
const gridSpecs = []
for (const [fam, fdesc] of FAMILIES)
for (const [ang, adesc] of ANGLES)
for (const asset of ASSETS)
gridSpecs.push({ fam, fdesc, ang, adesc, asset })
const gridTasks = gridSpecs.map((s) => () => agent(
CONTEXT +
`\n\nIL TUO CELLA:\n- FAMIGLIA FRATTALE: ${s.fam} -> ${s.fdesc}\n- ANGOLO REGIME: ${s.ang} -> ${s.adesc}\n- ASSET: ${s.asset}\n\n` +
`Progetta la MIGLIORE strategia in questa cella: un SEGNALE basato sulla famiglia frattale ${s.fam}, ` +
`condizionato/interagito col regime ${s.ang}. Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && ` +
`uv run python /tmp/<tuofile>.py), prova SIA TF=1h SIA TF=1d (e se vuoi 4h), itera 2-4 varianti di soglia/` +
`direzione/exit, e RIPORTA la migliore (quella con oos_sharpe piu alto e robust se possibile). Usa report()+robust(). ` +
`Privilegia mean-reversion (l'edge del progetto) ma testa anche momentum dove il regime lo motiva. ` +
`Mai look-ahead. Se tutto e rumore, dillo onestamente (promising=false). Ritorna lo schema.`,
{ label: `srch:${s.fam}/${s.ang}/${s.asset}`, phase: 'Search', schema: SCHEMA }))
// 8 wildcard: mandato aperto
const wildTasks = Array.from({ length: 8 }, (_, k) => () => agent(
CONTEXT +
`\n\nSEI UN AGENTE WILDCARD #${k + 1}. Mandato APERTO: inventa una combinazione FRATTALE-del-segnale x ` +
`REGIME-ARGO NON banale e non nella griglia ovvia. Idee: interazione hurst*vrp (mean-rev solo se ` +
`anti-persistente E vol sopravvalutata); Williams pivot come TP/SL adattivo gateato da dvol; analog kNN ` +
`pesato per funding; size/exit modulati dal regime; combinare 2 segnali frattali con conferma di regime. ` +
`Asset e TF a tua scelta (prova entrambi gli asset). Costruisci, testa onesto (report()+robust()), riporta ` +
`la migliore. Diversifica dagli altri: varia idea in base a #${k + 1}. Schema in output.`,
{ label: `wild:${k + 1}`, phase: 'Search', schema: SCHEMA }))
const searchResults = (await parallel([...gridTasks, ...wildTasks])).filter(Boolean)
// survivor = robust, oppure promising con oos_sharpe alto e abbastanza trade
const survivors = searchResults.filter(r =>
(r.robust || (r.promising && (r.oos_sharpe || 0) >= 1.0)) && (r.trades || 0) >= 30)
log(`Search: ${searchResults.length} testati, ${survivors.length} survivor da verificare`)
phase('Verify')
const VSCHEMA = {
type: 'object',
properties: {
strategy: { type: 'string' }, confirmed: { type: 'boolean' },
reason: { type: 'string', description: 'esito audit look-ahead + fee0.2% + altro asset + split alternativo' },
oos_sharpe_recheck: { type: 'number' }, killed_by: { type: 'string' },
},
required: ['strategy', 'confirmed', 'reason'],
}
let verified = []
if (survivors.length) {
verified = (await parallel(survivors.map(s => () => agent(
CONTEXT +
`\n\nVERIFICA AVVERSARIALE di un candidato survivor:\n${JSON.stringify(s, null, 1)}\n\n` +
`Tuo compito: PROVARE A FALSIFICARLO. (1) Ricostruisci la strategia (chiedi i dettagli dal suo edge_desc; ` +
`riusa regime_lab). (2) AUDIT look-ahead: ogni colonna/calcolo usa solo dati <= i? Il gate regime e' noto a i? ` +
`(3) Regge fee 0.2% RT in OOS? (4) Regge sull'ALTRO asset (se BTC prova ETH e viceversa)? (5) Regge a uno SPLIT ` +
`OOS alternativo (es. train<=2024, test 2025-26)? (6) Numero trade sufficiente e non concentrato in 1 anno? ` +
`Default a confirmed=FALSE se incerto o se sopravvive solo per overfit. Sii spietato. Schema in output.`,
{ label: `verify:${(s.strategy || '').slice(0, 24)}`, phase: 'Verify', schema: VSCHEMA })))).filter(Boolean)
}
const confirmed = verified.filter(v => v.confirmed)
phase('Synth')
const synthesis = await agent(
CONTEXT +
`\n\nHai i risultati di ${searchResults.length} agenti di ricerca e ${verified.length} verifiche avversariali.\n\n` +
`SURVIVOR CONFERMATI:\n${JSON.stringify(confirmed, null, 1)}\n\n` +
`TUTTI I SURVIVOR (anche non confermati):\n${JSON.stringify(survivors, null, 1)}\n\n` +
`TOP 15 per oos_sharpe fra tutti i testati:\n${JSON.stringify(
searchResults.slice().sort((a, b) => (b.oos_sharpe || 0) - (a.oos_sharpe || 0)).slice(0, 15), null, 1)}\n\n` +
`Produci la SINTESI FINALE (italiano) per il decisore:
1) VERDETTO: esiste una strategia frattale x ARGO con edge validato OOS? quale/i (confermate)?
2) Tabella dei top candidati: strategia, asset/tf, OOS Sharpe, OOS ret, DD, robust, confermato?
3) Il regime ARGO (DVOL/VRP/funding) AGGIUNGE valore al segnale frattale (vs angolo 'none')? In quali celle?
4) Cosa e' rumore e perche (coerente coi priori: frattali deboli, opzioni scartate).
5) Se c'e un vincitore: piano di implementazione (file in scripts/strategies/, MODULE_MAP, validazione finale).
Se NON c'e: dillo chiaro, niente forzature.
Cita NUMERI reali (OOS Sharpe, ret, trades). Onesta brutale: deve battere PORT06, non solo essere >0.`,
{ label: 'synthesis', phase: 'Synth' })
return { searchResults, survivors, confirmed, synthesis }
@@ -0,0 +1,231 @@
"""FC01 — Funding-carry market-neutral (ricerca, 2026-06-10).
Idea: su Deribit i long pagano gli short quando il funding e' positivo (e
viceversa). W12 (scartata) shortava il perp su funding alto = direzionale.
Qui il meccanismo NUOVO e' il CARRY NEUTRALE: short della gamba con funding
alto / long della gamba con funding basso (BTC vs ETH, dollar-neutral),
incassando il DIFFERENZIALE di funding con esposizione residua = solo lo
spread ETH/BTC (correlazione ~0.95).
Dati REALI: data/regime/{btc,eth}_funding.parquet (orario, 2019-12 -> 2026-06,
interest_1h effettivo + index_price). Causale: decisione al close t con
funding noto fino a t; accrual dal bar t+1; fee 0.10% RT per GAMBA.
Varianti:
FC-A spread-carry 2 gambe (il candidato): entra quando lo spread di funding
smussato supera la soglia, esce quando rientra / max_bars.
FC-B single-asset carry direzionale (confronto onesto con W12): short se
funding smussato > thr, long se < -thr.
Protocollo: TRAIN fino a OOS_DATE (2023-11-01) per scegliere la config,
OOS dopo; griglia robustezza; sweep fee; breakdown annuale.
uv run python scripts/analysis/funding_carry_research.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
FEE_RT = 0.001 # 0.10% RT per gamba (taker, baseline progetto)
OOS_DATE = "2023-11-01"
HRS_YEAR = 24 * 365
def load_panel():
btc = pd.read_parquet("data/regime/btc_funding.parquet")
eth = pd.read_parquet("data/regime/eth_funding.parquet")
for d in (btc, eth):
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms")
m = btc.set_index("dt")[["interest_1h", "index_price"]].rename(
columns={"interest_1h": "f_btc", "index_price": "p_btc"}).join(
eth.set_index("dt")[["interest_1h", "index_price"]].rename(
columns={"interest_1h": "f_eth", "index_price": "p_eth"}),
how="inner").sort_index()
m = m.dropna()
return m
def explore(m):
print("=" * 96)
print(" [0] ESPLORAZIONE — funding orario reale Deribit, "
f"{m.index[0].date()} -> {m.index[-1].date()} ({len(m)} ore)")
print("=" * 96)
for a in ("btc", "eth"):
f = m[f"f_{a}"] * HRS_YEAR * 100 # annualizzato %
print(f" {a.upper()}: funding annualizzato mean {f.mean():+6.2f}% "
f"med {f.median():+6.2f}% p10 {f.quantile(.1):+7.2f}% "
f"p90 {f.quantile(.9):+7.2f}% %ore>0 {100*(f>0).mean():.0f}%")
sp = (m["f_eth"] - m["f_btc"]) * HRS_YEAR * 100
print(f" SPREAD ETH-BTC annualizzato: mean {sp.mean():+6.2f}% "
f"p10 {sp.quantile(.1):+7.2f}% p90 {sp.quantile(.9):+7.2f}%")
# persistenza: autocorr dello spread smussato 24h a vari lag
s24 = (m["f_eth"] - m["f_btc"]).rolling(24).mean()
for lag in (24, 72, 168):
c = s24.autocorr(lag)
print(f" autocorr spread(24h-smooth) lag {lag:>4}h: {c:+.3f}")
# quanto duramo sopra soglia? episodi |spread ann| > 10%
thr = 0.10 / HRS_YEAR
above = (s24.abs() > thr).astype(int)
runs = (above.groupby((above != above.shift()).cumsum()).sum())
runs = runs[runs > 0]
if len(runs):
print(f" episodi |spread|>10% ann: {len(runs)} durata mediana "
f"{runs.median():.0f}h p90 {runs.quantile(.9):.0f}h")
# ---------------------------------------------------------------------------
# Backtest FC-A: spread-carry 2 gambe
# ---------------------------------------------------------------------------
def carry_pair(m, smooth=72, thr_ann=10.0, exit_frac=0.0, max_bars=24 * 30,
fee_rt=FEE_RT, sl=None):
"""Entra quando |spread smussato| > thr (annualizzato %); short la gamba
col funding alto, long l'altra, 1x notional per gamba. Esce quando lo
spread smussato scende sotto exit_frac*thr (o cambia segno) o max_bars.
Ritorna array di net-return per trade + serie equity oraria (additiva)."""
f_sp = (m["f_eth"] - m["f_btc"]).rolling(smooth).mean().to_numpy()
fe = m["f_eth"].to_numpy()
fb = m["f_btc"].to_numpy()
pe = m["p_eth"].to_numpy()
pb = m["p_btc"].to_numpy()
n = len(m)
thr = thr_ann / 100 / HRS_YEAR
ex = exit_frac * thr
sli = m.index[:n] if sl is None else None
rets, lens, accs = [], [], []
eq = np.zeros(n)
i = smooth
while i < n - 1:
s = f_sp[i]
if not np.isfinite(s) or abs(s) <= thr:
i += 1
continue
d = -1 if s > 0 else 1 # s>0: ETH paga di piu' -> short ETH/long BTC
e_eth, e_btc = pe[i], pb[i]
acc = 0.0
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
# accrual del funding sull'ora j: short riceve +f, long paga f
acc += (-d) * fe[j] + d * fb[j]
if abs(f_sp[j]) <= ex or np.sign(f_sp[j]) != np.sign(s):
break
j += 1
j = min(j, end)
price_leg = d * (pe[j] - e_eth) / e_eth - d * (pb[j] - e_btc) / e_btc
net = price_leg + acc - 2 * fee_rt
rets.append(net)
lens.append(j - i)
accs.append(acc)
eq[j] += net
i = j + 1
rets = np.array(rets)
eqs = pd.Series(eq, index=m.index).cumsum()
return rets, np.array(lens), np.array(accs), eqs
# ---------------------------------------------------------------------------
# Backtest FC-B: carry direzionale single-asset (confronto/W12 onesto)
# ---------------------------------------------------------------------------
def carry_single(m, asset="eth", smooth=72, thr_ann=20.0, exit_frac=0.0,
max_bars=24 * 30, fee_rt=FEE_RT):
f = m[f"f_{asset}"].rolling(smooth).mean().to_numpy()
fr = m[f"f_{asset}"].to_numpy()
p = m[f"p_{asset}"].to_numpy()
n = len(m)
thr = thr_ann / 100 / HRS_YEAR
ex = exit_frac * thr
rets = []
i = smooth
while i < n - 1:
s = f[i]
if not np.isfinite(s) or abs(s) <= thr:
i += 1
continue
d = -1 if s > 0 else 1 # funding alto -> short (incassa)
e = p[i]
acc = 0.0
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
acc += (-d) * fr[j]
if abs(f[j]) <= ex or np.sign(f[j]) != np.sign(s):
break
j += 1
j = min(j, end)
net = d * (p[j] - e) / e + acc - fee_rt
rets.append(net)
i = j + 1
return np.array(rets)
def stats(rets, idx_len_hours, label="", lens=None, accs=None):
if len(rets) == 0:
return f" {label:<28s} 0 trade"
yrs = idx_len_hours / HRS_YEAR
pnl = rets.sum() * 100
win = (rets > 0).mean() * 100
tpy = len(rets) / yrs
sh = rets.mean() / (rets.std() + 1e-12) * np.sqrt(max(tpy, 1e-9))
extra = ""
if lens is not None and len(lens):
extra = f" | hold med {np.median(lens):.0f}h"
if accs is not None and len(accs):
extra += f" | carry quota {100*np.sum(accs)/max(np.sum(rets),1e-9):.0f}%"
return (f" {label:<28s} {len(rets):>4d} tr | win {win:>4.0f}% | "
f"PnL {pnl:>+7.1f}% | {tpy:>5.1f} tr/anno | Sh {sh:>5.2f}{extra}")
def main():
m = load_panel()
explore(m)
cut = m.index.searchsorted(pd.Timestamp(OOS_DATE))
mtr, moo = m.iloc[:cut], m.iloc[cut:]
print(f"\n TRAIN {m.index[0].date()} -> {OOS_DATE} | OOS -> {m.index[-1].date()}")
print("\n" + "=" * 96)
print(" [1] FC-A spread-carry 2 gambe (fee 0.10% RT x2 gambe) — griglia su TRAIN")
print("=" * 96)
grid = []
for smooth in (24, 72, 168):
for thr in (5.0, 10.0, 20.0):
r, ln, ac, _ = carry_pair(mtr, smooth=smooth, thr_ann=thr)
grid.append((smooth, thr, r))
print(stats(r, len(mtr), f"TRAIN s{smooth} thr{thr:.0f}%", ln, ac))
print("\n Le stesse config in OOS (mai usate per scegliere):")
for smooth in (24, 72, 168):
for thr in (5.0, 10.0, 20.0):
r, ln, ac, _ = carry_pair(moo, smooth=smooth, thr_ann=thr)
print(stats(r, len(moo), f"OOS s{smooth} thr{thr:.0f}%", ln, ac))
print("\n" + "=" * 96)
print(" [2] FC-B carry direzionale single-asset (confronto, fee 0.10% RT)")
print("=" * 96)
for a in ("btc", "eth"):
for thr in (10.0, 30.0):
rtr = carry_single(mtr, a, thr_ann=thr)
roo = carry_single(moo, a, thr_ann=thr)
print(stats(rtr, len(mtr), f"TRAIN {a} thr{thr:.0f}%"))
print(stats(roo, len(moo), f"OOS {a} thr{thr:.0f}%"))
print("\n" + "=" * 96)
print(" [3] FC-A: sweep fee (config mediana s72 thr10) e breakdown annuale")
print("=" * 96)
for fee in (0.0005, 0.001, 0.002):
r, ln, ac, _ = carry_pair(m, smooth=72, thr_ann=10.0, fee_rt=fee)
print(stats(r, len(m), f"FULL fee {fee*100:.2f}% RT/gamba", ln, ac))
_, _, _, eq = carry_pair(m, smooth=72, thr_ann=10.0)
yr = eq.groupby(eq.index.year).apply(lambda s: (s.iloc[-1] - s.iloc[0]) * 100)
print(" annuale (PnL additivo %):",
{int(k): round(float(v), 1) for k, v in yr.items()})
if __name__ == "__main__":
main()
+332
View File
@@ -0,0 +1,332 @@
"""GATE PORT06 — griglia ETH (vincitore gioco "Grid Traders", sessione 3).
Il gioco (scripts/games/grid_*, regola STRATEGIA_GRIGLIA.md) ha promosso una
griglia geometrica asimmetrica su ETH: range profondo sotto, corto sopra,
4 livelli (passo ~5%), SL catastrofale. Ma il motore del gioco somma i PnL
REALIZZATI per trade e NON misura l'equity mark-to-market: l'inventario a
tranche dentro un drawdown e' rischio vero che il fitness non vede.
Questo gate risponde alla domanda "si puo' inserire?" con il metodo del progetto:
[1] STANDALONE mark-to-market (engine MTM dedicato, fill onesti):
equity per barra = capitale + inventario valutato al close; fee 0.10% RT
(taker; i fill ai livelli sarebbero LIMIT->maker, quindi conservativo);
SL gap-aware (gap sotto lo stop -> fill all'open, non al livello);
flat-skip (nessun fill sulle candele O=H=L=C di ETH 15m, live-realizable).
Metriche FULL/OOS con le stesse funzioni degli altri gate + stress fee 2x.
[2] CORRELAZIONE coi 19 sleeve PORT06 (il sospetto: e' la stessa reversione
ETH delle fade MR, incassata con inventory risk).
[3] ROBUSTEZZA: plateau range_down x range_up attorno al vincitore.
[4] GATE PORT06: baseline vs +GRID (full e half size). Promosso solo se
OOS Sharpe non peggiora E DD non sale (criterio standard).
uv run python scripts/analysis/grid_game_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE, IDX
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
from src.data.downloader import load_data
POS, LEV = 0.15, 3.0 # config canonica sleeve (== build_everything)
FEE_SIDE = 0.0005 # 0.05%/lato = 0.10% RT
# top-3 del torneo (data/games/grid_result.json)
WINNER_15M = dict(tf="15m", range_down=0.171, range_up=0.046, levels=4,
sl_buf=0.124, tp_buf=0.048, max_bars=2143)
TOP2_30M = dict(tf="30m", range_down=0.158, range_up=0.048, levels=4,
sl_buf=0.081, tp_buf=0.044, max_bars=613)
TOP3_1H = dict(tf="1h", range_down=0.134, range_up=0.053, levels=4,
sl_buf=0.150, tp_buf=0.063, max_bars=562)
_RESAMPLE = {"30m": ("15m", "30min")}
def _load(asset, tf):
if tf in _RESAMPLE:
base, rule = _RESAMPLE[tf]
d = load_data(asset, base).copy()
d["dt"] = pd.to_datetime(d["datetime"])
g = d.set_index("dt").resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last",
"volume": "sum"}).dropna(subset=["open", "close"]).reset_index()
g["datetime"] = g["dt"]
return g
d = load_data(asset, tf).copy()
d["dt"] = pd.to_datetime(d["datetime"])
return d
def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf,
max_bars, pos=POS, lev=LEV, fee_side=FEE_SIDE, flat_skip=True,
close_only=False, deploy_mask=None, df=None):
"""Griglia STRATEGIA_GRIGLIA.md con contabilita' mark-to-market.
Ritorna (equity daily Series base 1.0, stats dict). Causale: deploy sul
close, fill dalle barre successive lungo il percorso O->L->H->C / O->H->L->C.
`deploy_mask` (opzionale, np.bool array lungo come la serie, causale): se
fornito, una NUOVA griglia si deploya SOLO dove mask[j]=True (regime-gate);
None = comportamento storico (deploy sempre). Una griglia gia' attiva non
viene interrotta dal mask (gestisce il suo episodio fino a SL/TP/timeout).
`df` (opzionale): OHLCV gia' caricato (per il feed LIVE del GridWorker); None
= carica da _load(asset, tf) (comportamento storico, parita' col gate).
"""
df = _load(asset, tf) if df is None else df
op = df["open"].to_numpy(float)
hi = df["high"].to_numpy(float)
lo = df["low"].to_numpy(float)
cl = df["close"].to_numpy(float)
dt = (pd.to_datetime(df["datetime"]) if "datetime" in df.columns
else pd.to_datetime(df["timestamp"], unit="ms", utc=True)).to_numpy()
n = len(cl)
ratio = ((1 + range_up) / (1 - range_down)) ** (1.0 / levels)
if ratio - 1 <= 1.5 * 2 * fee_side: # vincolo break-even §4
raise ValueError("break-even violato")
flat = (op == hi) & (op == lo) & (op == cl)
capital = 1.0
eq = np.empty(n)
eq[:20] = 1.0
# stato episodio
active = False
lv = []; filled = []; tn = 0.0; sl = tp = 0.0; ep_end = 0
n_open = 0
trades = wins = stops = 0
deploy_i = -1
def mtm(px):
u = 0.0
for k in range(levels):
if filled[k]:
u += tn * (px / lv[k] - 1.0)
return capital + u
i = 20
for j in range(20, n):
if not active:
if deploy_mask is not None and not deploy_mask[j]:
eq[j] = capital # regime-gate: niente deploy, resta in cash
continue
# deploy sul close di j (fill da j+1)
px = cl[j]
rl_ = px * (1 - range_down)
lv = [rl_ * ratio ** k for k in range(levels + 1)]
sl = rl_ * (1 - sl_buf)
tp = lv[levels] * (1 + tp_buf)
filled = [False] * levels
n_open = 0
tn = capital * pos * lev / levels # notional per tranche
ep_end = j + max_bars
active = True
deploy_i = j
eq[j] = capital
continue
if flat_skip and flat[j]:
eq[j] = mtm(cl[j])
continue
cur = cl[j - 1]
if close_only:
# fill solo su attraversamento del CLOSE (le wick non fillano):
# stress anti-spike-print del feed testnet
pts = (cl[j],)
else:
pts = (op[j], lo[j], hi[j], cl[j]) if cl[j] >= op[j] \
else (op[j], hi[j], lo[j], cl[j])
died = False
for pi, q in enumerate(pts):
q = float(q)
if q == cur:
continue
if q < cur:
from bisect import bisect_left
k1 = bisect_left(lv, q)
k2 = bisect_left(lv, cur) - 1
for k in range(min(k2, levels - 1), max(k1, 0) - 1, -1):
if not filled[k]:
filled[k] = True
n_open += 1
capital -= fee_side * tn # fee ingresso
if q <= sl:
# STOP: gap all'open -> fill all'open, altrimenti al livello sl
fill = q if (pi == 0 and q <= sl) else sl
for k in range(levels):
if filled[k]:
r = fill / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
trades += 1
stops += 1
n_open = 0
died = True
cur = q
break
else:
from bisect import bisect_right
m1 = bisect_right(lv, cur)
m2 = bisect_right(lv, q) - 1
for m in range(max(m1, 1), min(m2, levels) + 1):
k = m - 1
if filled[k]:
r = lv[m] / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
n_open -= 1
trades += 1
wins += 1
if q >= tp:
for k in range(levels):
if filled[k]:
r = tp / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
trades += 1
wins += 1
n_open = 0
died = True
cur = q
break
cur = q
if not died and j >= ep_end:
# timeout: liquida al close
for k in range(levels):
if filled[k]:
r = cl[j] / lv[k]
capital += tn * (r - 1.0) - fee_side * tn * r
filled[k] = False
trades += 1
wins += r > 1.0 + 2 * fee_side
n_open = 0
died = True
if died:
active = False
eq[j] = capital
else:
eq[j] = mtm(cl[j])
s = pd.Series(eq, index=pd.DatetimeIndex(dt)).resample("1D").last().dropna()
s = s / s.iloc[0]
return s, dict(trades=trades, win=100.0 * wins / max(1, trades), stops=stops)
def std(eqd):
"""Metriche FULL/OOS con le funzioni standard del progetto."""
e = eqd.reindex(IDX).ffill().bfill()
dr = e.pct_change().fillna(0.0)
return metrics(dr), metrics(dr, lo=SPLIT)
def main():
p = PORTFOLIOS["PORT06"]
print("=" * 100)
print(" GATE PORT06 — griglia ETH (vincitore gioco Grid Traders) | "
f"pos={POS} lev={LEV} | OOS da {OOS_DATE}")
print("=" * 100)
# [1] STANDALONE mark-to-market
print("\n[1] STANDALONE mark-to-market (fee 0.10% RT, flat-skip, SL gap-aware):")
print(f" {'cfg':<22s}{'trd':>7s}{'win%':>6s}{'stops':>6s}{'FULL%':>8s}{'CAGR%':>7s}"
f"{'DD%':>7s}{'Shrp':>6s} | {'OOS%':>7s}{'oDD%':>6s}{'oShrp':>6s}")
eqs = {}
for tag, cfg in [("WINNER 15m", WINNER_15M), ("top2 30m", TOP2_30M),
("top3 1h", TOP3_1H)]:
eqd, st = grid_mtm("ETH", **cfg)
f, o = std(eqd)
eqs[tag] = eqd
print(f" {tag:<22s}{st['trades']:>7d}{st['win']:>6.1f}{st['stops']:>6d}"
f"{f['ret']:>+8.0f}{f['cagr']:>7.1f}{f['dd']:>7.2f}{f['sharpe']:>6.2f}"
f" | {o['ret']:>+7.0f}{o['dd']:>6.2f}{o['sharpe']:>6.2f}")
# stress: fee 2x e no flat-skip sul winner
eq2, st2 = grid_mtm("ETH", **WINNER_15M, fee_side=0.001)
f2, o2 = std(eq2)
eqnf, _ = grid_mtm("ETH", **WINNER_15M, flat_skip=False)
fnf, onf = std(eqnf)
print(f" {'winner fee 2x':<22s}{st2['trades']:>7d}{st2['win']:>6.1f}{'':>6s}"
f"{f2['ret']:>+8.0f}{f2['cagr']:>7.1f}{f2['dd']:>7.2f}{f2['sharpe']:>6.2f}"
f" | {o2['ret']:>+7.0f}{o2['dd']:>6.2f}{o2['sharpe']:>6.2f}")
print(f" {'winner no-flat-skip':<22s}{'':>7s}{'':>6s}{'':>6s}"
f"{fnf['ret']:>+8.0f}{fnf['cagr']:>7.1f}{fnf['dd']:>7.2f}{fnf['sharpe']:>6.2f}"
f" | {onf['ret']:>+7.0f}{onf['dd']:>6.2f}{onf['sharpe']:>6.2f}")
grid_eq = eqs["WINNER 15m"]
# [2] CORRELAZIONI coi sleeve PORT06
from src.portfolio.sleeves import all_sleeve_equities
eq_base = dict(all_sleeve_equities())
gr = grid_eq.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
print("\n[2] CORRELAZIONE rendimenti giornalieri GRID_ETH15M vs sleeve PORT06 (top 8):")
cors = {}
for sid, e in eq_base.items():
r = e.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
cors[sid] = gr.corr(r)
for sid, cv in sorted(cors.items(), key=lambda kv: -abs(kv[1]))[:8]:
print(f" {sid:<16s} {cv:+.3f}")
# [3] ROBUSTEZZA: plateau range_down x range_up (15m, levels=4)
print("\n[3] ROBUSTEZZA 15m (Sharpe FULL mark-to-market, levels=4, "
"sl_buf=0.12, tp_buf=0.05, max_bars=2000):")
rds = [0.13, 0.15, 0.17, 0.19]
rus = [0.04, 0.05, 0.06, 0.08]
print(" rd\\ru " + "".join(f"{ru:>7.2f}" for ru in rus))
cells = tot = 0
for rd in rds:
row = f" {rd:>5.2f} "
for ru in rus:
eqd, _ = grid_mtm("ETH", tf="15m", range_down=rd, range_up=ru,
levels=4, sl_buf=0.12, tp_buf=0.05, max_bars=2000)
f, o = std(eqd)
tot += 1
cells += (f["sharpe"] > 1) and (o["sharpe"] > 1)
row += f"{f['sharpe']:>7.2f}"
print(row)
print(f" -> {cells}/{tot} celle con Sharpe>1 sia FULL che OOS")
# [4] GATE PORT06
print("\n[4] GATE PORT06 — baseline vs +GRID_ETH15M (full/half size):")
def port_m(extra=None):
members = dict(eq_base)
ids = list(p.sleeve_ids)
if extra is not None:
members["GRID_ETH15M"] = extra
ids = ids + ["GRID_ETH15M"]
dr = pd.DataFrame({i: members[i].reindex(IDX).ffill().bfill()
.pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i].reindex(IDX).ffill().bfill()
for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
half = (1 + 0.5 * gr).cumprod()
res = {"baseline": port_m(None),
"+GRID full": port_m(grid_eq),
"+GRID half": port_m(half)}
print(f" {'variante':<12s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR%':>7s}"
f" | {'OOS Sh':>7s}{'OOS DD%':>8s}")
for tag, (f, o) in res.items():
print(f" {tag:<12s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>7.1f}"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f}")
fb, ob = res["baseline"]
print("\n" + "=" * 100)
print(" VERDETTO (criterio standard: OOS Sharpe non peggiora E DD non sale)")
print("=" * 100)
for tag in ("+GRID full", "+GRID half"):
f, o = res[tag]
ok = (o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9
and f["sharpe"] >= fb["sharpe"] - 0.02)
print(f" {tag:<12s}: OOS Sh {ob['sharpe']:.2f}->{o['sharpe']:.2f} "
f"DD {ob['dd']:.2f}->{o['dd']:.2f} | FULL Sh {fb['sharpe']:.2f}->{f['sharpe']:.2f} "
f"DD {fb['dd']:.2f}->{f['dd']:.2f} => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
"""Validazione FINALE delle 3 strategie oneste selezionate.
Per ciascuna: per-asset FULL/OOS/DD/anni-positivi + sweep fee (0/0.05/0.10/0.20% RT).
Tutto NETTO, ingresso eseguibile, OOS = ultimo 30%, leva 3x.
S1 DIP — long-only dip-buy z-score reversion (1h) [regime: reversione]
S2 TREND — long-only EMA 20/100 trend-following (4h) [regime: momentum singolo]
S3 ROT — rotazione cross-sectional momentum sul paniere (1d) [regime: forza relativa]
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, simulate, oos_split, available_assets
from scripts.analysis.honest_trend import simulate_position, ema_dual_signal, oos as trend_oos
from scripts.analysis.honest_rotation import build_panel, simulate_rotation
FEES = [0.0, 0.0005, 0.001, 0.002]
# ---- S1 DIP ----
def dip_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
ents = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
return ents
def validate_dip(assets):
print("\n" + "=" * 100)
print(" S1 DIP — long-only dip-buy z-score reversion | 1h | n=50 z=2.5 sl=2.5ATR mb=24")
print("=" * 100)
print(f" {'Asset':<6s}{'Trd':>6s}{'Win%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}"
f"{' fee-sweep OOS% (0/0.05/0.10/0.20)':<40s}")
ok = 0
for a in assets:
df = get_df(a, "1h"); ents = dip_entries(df)
if len(ents) < 30:
continue
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
sweep = " ".join(f"{simulate(oe, df, fee_rt=f).ret:+.0f}" for f in FEES)
good = full.ret > 0 and oos.ret > 0
ok += good
print(f" {a:<6s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}{oos.ret:>+9.0f}"
f"{full.dd:>6.0f}{full.exposure:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s} [{sweep}]"
f"{' OK' if good else ''}")
print(f" -> robusto (FULL+OOS>0) su {ok}/{len(assets)} asset")
def validate_trend(assets):
print("\n" + "=" * 100)
print(" S2 TREND — long-only EMA 20/100 trend | 4h")
print("=" * 100)
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
ok = 0
for a in assets:
df = get_df(a, "4h"); sig = ema_dual_signal(df, 20, 100, long_only=True)
full = simulate_position(sig, df); oos = trend_oos(sig, df)
good = full["ret"] > 0 and oos["ret"] > 0
ok += good
print(f" {a:<6s}{full['flips']:>6d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{(str(full['pos_years'])+'/'+str(full['n_years'])):>8s}"
f"{' OK' if good else ''}")
print(f" -> robusto su {ok}/{len(assets)} asset")
def validate_rot(assets):
print("\n" + "=" * 100)
print(" S3 ROT — rotazione cross-sectional momentum | 1d | lb=60 top2 su tutto il paniere")
print("=" * 100)
panel = build_panel(assets, "1d")
print(f" Paniere {list(panel.columns)} {panel.shape[0]} barre {panel.index[0].date()}->{panel.index[-1].date()}")
print(f" {'fee RT':<10s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'AnniP':>8s}")
for f in FEES:
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f)
oos = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f, oos_frac=0.30)
anni = str(full['pos_years']) + '/' + str(full['n_years'])
print(f" {f*100:>5.2f}%RT {full['ret']:>+9.0f}{oos['ret']:>+9.0f}{full['dd']:>6.0f}{anni:>8s}")
# per-anno alla fee reale
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=0.001)
print(" per-anno (fee 0.10%): " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
if __name__ == "__main__":
assets = available_assets()
print(f"VALIDAZIONE FINALE — asset disponibili: {assets}")
validate_dip(assets)
validate_trend(assets)
validate_rot(assets)
+175
View File
@@ -0,0 +1,175 @@
"""Miglioramenti ONESTI: alzare Acc, ridurre DD, migliorare PnL senza overfitting.
Leve usate (tutte robuste e documentate, niente tuning sui singoli anni):
1. ABSOLUTE-MOMENTUM overlay (dual momentum): vai in CASH quando il "mercato"
(BTC) e' sotto la sua media di lungo periodo -> taglia i bear (2022/2026).
2. VOL-TARGETING: scala l'esposizione per puntare a una volatilita' costante
-> riduce il DD e liscia la PnL.
3. TRAILING STOP ad ATR per il trend (TR01) -> blocca i profitti.
Confronto base vs migliorata su FULL + OOS + DD pieno + per-anno.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
from scripts.analysis.honest_rotation import build_panel
LEV, POS = 3.0, 0.15
def _dd(eq: np.ndarray) -> float:
peak = eq[0]; mx = 0.0
for v in eq:
peak = max(peak, v); mx = max(mx, (peak - v) / peak if peak > 0 else 0.0)
return mx * 100
# ============================================================================
# ROT01 migliorata: dual-momentum (cash se BTC < SMA) + vol-target
# ============================================================================
def rot_improved(lookback=60, top_k=2, gross=0.45, regime_n=100,
target_vol=0.0, vol_n=20, fee_rt=FEE_RT, oos_frac=0.0):
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns)
P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
btc = P[:, cols.index("BTC")]
use_regime = regime_n and regime_n > 1
btc_ma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
# vol realizzata del portafoglio equal-weight come proxy di scala
mkt_ret = rets.mean(axis=1)
rv = pd.Series(mkt_ret).rolling(vol_n).std().values * np.sqrt(365)
start = max(lookback + 1, (regime_n + 1) if use_regime else 0, int(T * (1 - oos_frac)) if oos_frac else 0)
cap = 1000.0; w = np.zeros(N)
eq = [cap]; yearly: dict[int, float] = {}; pos_days = {}; days = {}; reb = {}
for i in range(start, T - 1):
if use_regime:
risk_on = btc[i] > btc_ma[i] if not np.isnan(btc_ma[i]) else False
else:
risk_on = True
mom = P[i] / P[i - lookback] - 1
order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
g = gross
if target_vol > 0 and not np.isnan(rv[i]) and rv[i] > 0:
g = min(gross, gross * target_vol / rv[i]) # solo riduzione (no leva extra)
new_w = np.zeros(N)
for j in chosen:
new_w[j] = g / len(chosen)
turnover = np.abs(new_w - w).sum()
if turnover > 1e-9:
cap -= cap * turnover * (fee_rt / 2)
w = new_w
pr = float(np.dot(w, rets[i + 1]))
cap = max(cap * (1 + pr), 10.0)
eq.append(cap)
y = int(years[i])
yearly[y] = yearly.get(y, 0.0) + pr * 100
pos_days[y] = pos_days.get(y, 0) + (pr > 0); days[y] = days.get(y, 0) + 1
reb[y] = reb.get(y, 0) + (turnover > 1e-9)
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)), "yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0), "n_years": len(yearly),
"pos_days": pos_days, "days": days, "reb": reb}
# ============================================================================
# DIP01 migliorata: filtro regime (no dip in bear forte) + vol-target sizing
# ============================================================================
def dip_improved(asset, tf="1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
regime_n=200, vol_target=0.0, fee_rt=FEE_RT, oos_frac=0.0):
df = get_df(asset, tf)
h, l, c = df["high"].values, df["low"].values, df["close"].values
N = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
sma_r = pd.Series(c).rolling(regime_n).mean().values
atr_pct = a / c # volatilita' relativa
base_vol = np.nanmedian(atr_pct[regime_n:regime_n * 2]) if N > regime_n * 2 else np.nanmedian(atr_pct)
fee = fee_rt * LEV
cap = 1000.0; last_exit = -1
eq = [cap]; yt: dict[int, list] = {}
start = max(n + 14, regime_n + 1) if regime_n else n + 14
split = int(N * (1 - oos_frac)) if oos_frac else 0
for i in range(start, N):
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
continue
if not (z[i] <= -z_in and z[i - 1] > -z_in):
continue
# filtro regime: salta i dip in bear forte (prezzo molto sotto SMA lunga)
if regime_n and not np.isnan(sma_r[i]) and c[i] < sma_r[i] * 0.90:
continue
if i <= last_exit or i + 1 >= N:
continue
# vol-target: riduci posizione se ATR% > base (no leva extra)
psize = POS
if vol_target > 0 and not np.isnan(atr_pct[i]) and atr_pct[i] > 0:
psize = POS * min(1.0, base_vol / atr_pct[i])
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
for k in range(1, mb + 1):
j = i + k
if j >= N:
j = N - 1; exit_p = c[j]; break
if l[j] <= sl:
exit_p = sl; break
if h[j] >= tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * LEV - fee
cap = max(cap + cap * psize * ret, 10.0)
last_exit = j
y = ts.iloc[i].year
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
eq.append(cap)
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)),
"trades": t, "acc": w / t * 100 if t else 0.0,
"yt": yt, "pos_years": sum(1 for v in yt.values() if v[1] / max(v[0],1) and v[1]>v[0]*0 and (v[1]>0)), "n_years": len(yt)}
def dip_acc_pnl(asset, **kw):
"""ritorna anche FULL e OOS."""
full = dip_improved(asset, **kw)
oos = dip_improved(asset, oos_frac=0.30, **kw)
return full, oos
if __name__ == "__main__":
print("=" * 92)
print(" ROT01 — BASE vs MIGLIORATA (dual-momentum cash + vol-target)")
print("=" * 92)
print(f" {'config':<40s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}{'AnniP':>8s}")
b = rot_improved(regime_n=0); bo = rot_improved(regime_n=0, oos_frac=0.30)
print(f" {'BASE (no overlay)':<40s}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}{b['dd']:>10.0f}"
f"{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
for rn in [100, 150, 200]:
f = rot_improved(regime_n=rn); o = rot_improved(regime_n=rn, oos_frac=0.30)
print(f" {'+ dual-mom cash (BTC<SMA'+str(rn)+')':<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
for tv in [0.6, 0.8]:
f = rot_improved(regime_n=150, target_vol=tv); o = rot_improved(regime_n=150, target_vol=tv, oos_frac=0.30)
print(f" {'+ dual-mom150 + volTarget'+str(tv):<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
print("\n" + "=" * 92)
print(" DIP01 — BASE vs MIGLIORATA (filtro regime + vol-target)")
print("=" * 92)
print(f" {'asset / config':<34s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}")
for a in ["BTC", "ETH", "SOL"]:
for label, kw in [("base", dict(regime_n=0, vol_target=0)),
("+regime+volTgt", dict(regime_n=200, vol_target=0.5))]:
f, o = dip_acc_pnl(a, **kw)
print(f" {a+' '+label:<34s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}"
f"{o['ret']:>+9.0f}{f['dd']:>10.0f}")
+184
View File
@@ -0,0 +1,184 @@
"""Miglioramenti v2: market-regime gate su DIP01 + PORTAFOGLIO combinato.
- DIP01 con gate di mercato: compra i dip solo quando BTC e' risk-on (BTC>SMA),
cosi' si evitano le capitolazioni dei bear (2018/2022) che peggiorano Acc/DD/PnL.
- Portafoglio: equal-weight giornaliero delle 3 strategie migliorate -> la
diversificazione taglia il DD mantenendo la PnL (migliora il risk-adjusted).
Tutto NETTO, con DD pieno e per-anno.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
from scripts.analysis.honest_improve import rot_improved, _dd
LEV, POS = 3.0, 0.15
def _daily_equity(ts_list, cap_list, idx):
"""serie di equity giornaliera (ffill) su un DatetimeIndex comune."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
daily = s.resample("1D").last().reindex(idx).ffill().bfill()
return daily
# ---------- DIP01 con market-regime gate ----------
def dip_market_gated(asset, n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
market_n=100, fee_rt=FEE_RT, oos_frac=0.0, return_equity=False):
df = get_df(asset, "1h")
h, l, c = df["high"].values, df["low"].values, df["close"].values
N = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
# regime di mercato: BTC 1h > SMA(market_n in giorni -> *24 barre)
btc = get_df("BTC", "1h")
bser = pd.Series(btc["close"].values,
index=pd.to_datetime(btc["timestamp"], unit="ms", utc=True))
bser = bser[~bser.index.duplicated()]
bma = bser.rolling(market_n * 24).mean()
risk_on = (bser > bma).reindex(ts, method="ffill").fillna(False).values
fee = fee_rt * LEV
cap = 1000.0; last_exit = -1
eq_ts, eq_v = [], []
yt: dict[int, list] = {}; ypnl: dict[int, float] = {}
split = int(N * (1 - oos_frac)) if oos_frac else 0
for i in range(n + 14, N):
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
continue
if not (z[i] <= -z_in and z[i - 1] > -z_in):
continue
if market_n and not risk_on[i]:
continue
if i <= last_exit or i + 1 >= N:
continue
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
for k in range(1, mb + 1):
j = i + k
if j >= N:
j = N - 1; exit_p = c[j]; break
if l[j] <= sl:
exit_p = sl; break
if h[j] >= tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * LEV - fee
cap = max(cap + cap * POS * ret, 10.0)
last_exit = j
y = ts.iloc[i].year
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
ypnl[y] = ypnl.get(y, 0.0) + ret * 100
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
out = {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq_v)) if eq_v else 0.0,
"trades": t, "acc": w / t * 100 if t else 0.0, "yt": yt, "ypnl": ypnl,
"pos_years": sum(1 for v in ypnl.values() if v > 0), "n_years": len(ypnl)}
if return_equity:
out["eq_ts"], out["eq_v"] = eq_ts, eq_v
return out
def main():
print("=" * 96)
print(" DIP01 — base vs MARKET-GATE (compra dip solo se BTC>SMA100)")
print("=" * 96)
print(f" {'asset / config':<30s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>7s}{'AnniP':>8s}")
for a in ["BTC", "ETH", "SOL"]:
b = dip_market_gated(a, market_n=0); bo = dip_market_gated(a, market_n=0, oos_frac=0.30)
g = dip_market_gated(a, market_n=100); go = dip_market_gated(a, market_n=100, oos_frac=0.30)
print(f" {a+' base':<30s}{b['trades']:>6d}{b['acc']:>7.1f}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}"
f"{b['dd']:>7.0f}{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
print(f" {a+' +gate100':<30s}{g['trades']:>6d}{g['acc']:>7.1f}{g['ret']:>+9.0f}{go['ret']:>+9.0f}"
f"{g['dd']:>7.0f}{str(g['pos_years'])+'/'+str(g['n_years']):>8s}")
# ---------- PORTAFOGLIO combinato (3 sleeve diversificate) ----------
print("\n" + "=" * 96)
print(" PORTAFOGLIO equal-weight giornaliero (ribilanciato): DIP01 + TR01-basket + ROT02")
print("=" * 96)
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
# sleeve 1: DIP01 base su BTC (la migliore)
d = dip_market_gated("BTC", market_n=0, return_equity=True)
eq_dip = _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx))
# sleeve 2: TR01 equal-weight su {BNB,BTC,DOGE,SOL,XRP}
eq_tr = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx))
# sleeve 3: ROT02 dual-momentum
eq_rot = _norm(_rot_daily_equity(idx))
members = {"DIP01_BTC": eq_dip, "TR01_basket": eq_tr, "ROT02_dualmom": eq_rot}
# ribilanciamento giornaliero equal-weight: media dei rendimenti giornalieri
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in members.items()})
port_ret = drets.mean(axis=1)
combo = (1 + port_ret).cumprod()
print(f" Periodo {idx[0].date()} -> {idx[-1].date()} (leva/pos gia' incluse nelle sleeve)")
print(f" {'sleeve':<16s}{'ret%':>9s}{'DD%':>7s}{'CAGR%':>8s}")
yrs = (idx[-1] - idx[0]).days / 365.25
for name, s in members.items():
r = (s.iloc[-1] / s.iloc[0] - 1) * 100
cagr = ((s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {name:<16s}{r:>+9.0f}{_dd(s.values):>7.0f}{cagr:>8.0f}")
r = (combo.iloc[-1] / combo.iloc[0] - 1) * 100
cagr = ((combo.iloc[-1] / combo.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {'PORTAFOGLIO':<16s}{r:>+9.0f}{_dd(combo.values):>7.0f}{cagr:>8.0f} <-- DD molto piu' basso, CAGR solida")
# per-anno del portafoglio
pa = (port_ret.groupby(port_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100))
print(" Portafoglio per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
def _norm(s):
return s / s.iloc[0]
def _tr_basket_daily(assets, idx):
"""equity giornaliera media di TR01 (EMA20/100 long-only, 4h) sul paniere."""
eqs = []
for a in assets:
df = get_df(a, "4h"); c = df["close"].values; n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ef, es = ema(c, 20), ema(c, 100)
sig = np.where(ef > es, 1.0, 0.0); sig[:100] = 0.0
cap = 1000.0; cur = 0.0; fee = FEE_RT / 2 * LEV
tl, cl = [], []
for i in range(n - 1):
s = sig[i]
if s != cur:
cap -= cap * POS * fee * abs(s - cur); cur = s
cap = max(cap * (1 + POS * LEV * (c[i + 1] - c[i]) / c[i] * cur), 10.0)
tl.append(ts.iloc[i]); cl.append(cap)
eqs.append(_norm(_daily_equity(tl, cl, idx)))
return _norm(pd.concat(eqs, axis=1).mean(axis=1))
def _rot_daily_equity(idx):
"""equity giornaliera della ROT01 dual-momentum (ricostruita bar-by-bar)."""
from scripts.analysis.honest_rotation import build_panel
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns); P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
btc = P[:, cols.index("BTC")]; bma = pd.Series(btc).rolling(100).mean().values
cap = 1000.0; w = np.zeros(N); ts_list = []; cap_list = []
for i in range(101, T - 1):
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
mom = P[i] / P[i - 60] - 1; order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:3] if risk_on else [] # top_k=3 (era 2): DD piu' basso
nw = np.zeros(N)
for j in chosen:
nw[j] = 0.45 / len(chosen)
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
ts_list.append(panel.index[i]); cap_list.append(cap)
s = _daily_equity(ts_list, cap_list, idx); return s / s.iloc[0]
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
"""honest_lab — laboratorio di ricerca strategie ONESTO e fee-aware.
Principi (per non ripetere l'errore look-ahead della famiglia squeeze):
1. Ogni segnale a barra i usa SOLO dati fino a close[i]. Ingresso a close[i]
(eseguibile dal vivo: il worker vede la candela chiusa ed entra). Opzione
di robustezza: ingresso a open[i+1] (ancora piu' conservativo).
2. Uscita TP/SL valutata intrabar su high/low, conservativa: SL prima del TP
nello stesso bar. Time-limit max_bars. Una posizione per volta (non-overlap).
3. Tutto NETTO dopo fee round-trip realistiche (0.10% Deribit) * leva.
4. Validazione: FULL + OOS (held-out ultimo 30%) + per-anno + sweep fee
+ griglia parametri + su PIU' asset. Niente di tutto cio' -> scartata.
Engine condiviso riusabile da tutte le strategie candidate.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
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 # noqa: E402
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
LEV = 3.0
POS = 0.15
OOS_FRAC = 0.30
DATA_DIR = PROJECT_ROOT / "data" / "raw"
# ----------------------------------------------------------------------------
# dati
# ----------------------------------------------------------------------------
_CACHE: dict[tuple[str, str], pd.DataFrame] = {}
def available_assets() -> list[str]:
out = []
for p in sorted(DATA_DIR.glob("*_1h.parquet")):
name = p.stem.replace("_1h", "").upper()
if name not in ("BTC_DVOL", "ETH_DVOL"):
out.append(name)
return out
def get_df(asset: str, tf: str) -> pd.DataFrame:
"""tf nativo (15m,1h) o resample da 1h (2h,4h,6h,12h,1d)."""
key = (asset, tf)
if key in _CACHE:
return _CACHE[key]
if tf in ("15m", "1h"):
df = load_data(asset, tf).reset_index(drop=True)
else:
base = load_data(asset, "1h").copy()
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
base = base.set_index("dt")
rule = {"2h": "2h", "4h": "4h", "6h": "6h", "12h": "12h", "1d": "1D"}[tf]
agg = base.resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
# l'indice puo' essere datetime64[ms] o [ns]: forza ms in modo robusto
agg["timestamp"] = agg.index.values.astype("datetime64[ms]").astype("int64")
df = agg.reset_index(drop=True)
df = df[["timestamp", "open", "high", "low", "close", "volume"]].copy()
_CACHE[key] = df
return df
# ----------------------------------------------------------------------------
# indicatori
# ----------------------------------------------------------------------------
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
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 rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
d = np.diff(close, prepend=close[0])
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
rs = up / dn.replace(0, np.nan)
return (100 - 100 / (1 + rs)).values
def ema(close: np.ndarray, n: int) -> np.ndarray:
return pd.Series(close).ewm(span=n, adjust=False).mean().values
# ----------------------------------------------------------------------------
# engine
# ----------------------------------------------------------------------------
@dataclass
class SimResult:
trades: int
win: float
ret: float # ritorno % netto composto su 1000
dd: float
exposure: float
yearly: dict[int, float]
@property
def pos_years(self) -> int:
return sum(1 for v in self.yearly.values() if v > 0)
@property
def n_years(self) -> int:
return len(self.yearly)
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS, entry_on_open: bool = False) -> SimResult:
"""entries: dict {i, d(+1/-1), tp, sl, max_bars}.
entry_on_open=True -> ingresso a open[i+1] invece di close[i] (robustezza).
"""
o, h, l, c = (df["open"].values, df["high"].values,
df["low"].values, df["close"].values)
n = len(c)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = 0
last_exit = -1
bars_in = 0
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
yearly: dict[int, float] = {}
for e in entries:
i, d = e["i"], e["d"]
ei = i + 1 if entry_on_open else i # barra di ingresso
if ei <= last_exit or ei + 1 >= n:
continue
entry = o[ei] if entry_on_open else c[i]
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(ei + mb, n - 1)]
j = min(ei + mb, n - 1)
for k in range(1, mb + 1):
j = ei + k
if j >= n:
j = n - 1; exit_p = c[j]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl: # conservativo: SL prima del TP nello stesso bar
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; bars_in += (j - ei)
last_exit = j
yr = ts.iloc[i].year
yearly[yr] = yearly.get(yr, 0.0) + ret * 100
return SimResult(
trades=trades,
win=wins / trades * 100 if trades else 0.0,
ret=(cap / 1000 - 1) * 100,
dd=max_dd * 100,
exposure=bars_in / n * 100,
yearly=yearly,
)
def oos_split(entries: list[dict], df: pd.DataFrame, frac: float = OOS_FRAC):
split = int(len(df) * (1 - frac))
ins = [e for e in entries if e["i"] < split]
oos = [e for e in entries if e["i"] >= split]
return ins, oos
# ----------------------------------------------------------------------------
# criterio di accettazione
# ----------------------------------------------------------------------------
def verdict(full: SimResult, oos: SimResult) -> bool:
"""Strategia attendibile su un singolo asset/tf."""
if full.trades < 30:
return False
if full.ret <= 0 or oos.ret <= 0:
return False
if full.pos_years < max(full.n_years - 1, 1):
return False
if full.dd > 45:
return False
return True
+80
View File
@@ -0,0 +1,80 @@
"""Tabella unica consolidata: PnL% NETTO per anno, tutte le strategie a confronto.
Colonne: DIP01(BTC) · TR01(basket) · ROT01(base) · ROT02(dual-mom) · PORTAFOGLIO.
Ultima riga: TOT e DD full-period.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import available_assets, FEE_RT
from scripts.analysis.honest_improve import _dd
from scripts.analysis.honest_improve2 import (
dip_market_gated, _daily_equity, _norm, _tr_basket_daily,
)
from scripts.analysis.honest_rotation import build_panel
LEV, POS = 3.0, 0.15
def rot_daily(idx, regime_n=0, lookback=60, top_k=2, gross=0.45):
"""equity giornaliera della rotazione, con/senza overlay dual-momentum."""
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns); P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
btc = P[:, cols.index("BTC")]
bma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
use_reg = regime_n and regime_n > 1
cap = 1000.0; w = np.zeros(N); tl, cl = [], []
start = max(lookback + 1, regime_n + 1 if use_reg else 0)
for i in range(start, T - 1):
risk_on = (btc[i] > bma[i]) if (use_reg and not np.isnan(bma[i])) else True
mom = P[i] / P[i - lookback] - 1; order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
nw = np.zeros(N)
for j in chosen:
nw[j] = gross / len(chosen)
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
tl.append(panel.index[i]); cl.append(cap)
return _norm(_daily_equity(tl, cl, idx))
def year_pnl(eq):
return {int(y): (g.iloc[-1] / g.iloc[0] - 1) * 100 for y, g in _norm(eq).groupby(eq.index.year)}
if __name__ == "__main__":
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
d = dip_market_gated("BTC", market_n=0, return_equity=True)
cols = {
"DIP01(BTC)": _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx)),
"TR01(bskt)": _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx)),
"ROT01": rot_daily(idx, regime_n=0),
"ROT02": rot_daily(idx, regime_n=100),
}
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in {
"DIP01(BTC)": cols["DIP01(BTC)"], "TR01(bskt)": cols["TR01(bskt)"], "ROT02": cols["ROT02"]
}.items()})
cols["PORTAF."] = (1 + drets.mean(axis=1)).cumprod()
names = list(cols)
py = {n: year_pnl(cols[n]) for n in names}
years = sorted({y for n in names for y in py[n]})
print("=" * 78)
print(" PnL% NETTO PER ANNO — confronto strategie (leva 3x, fee 0.10% RT)")
print("=" * 78)
print(f" {'Anno':>6s}" + "".join(f"{n:>12s}" for n in names))
print(" " + "-" * 72)
for y in years:
print(f" {y:>6d}" + "".join(f"{py[n].get(y, float('nan')):>+12.0f}" if y in py[n] else f"{'-':>12s}" for n in names))
print(" " + "-" * 72)
print(f" {'TOT%':>6s}" + "".join(f"{(cols[n].iloc[-1]/cols[n].iloc[0]-1)*100:>+12.0f}" for n in names))
print(f" {'DDfull':>6s}" + "".join(f"{_dd(cols[n].values):>12.0f}" for n in names))
+96
View File
@@ -0,0 +1,96 @@
"""Strategia #3 candidata: ROTAZIONE cross-sectional momentum (multi-crypto).
Una sola strategia che usa l'INTERO paniere: ad ogni ribilanciamento alloca il
capitale agli asset con momentum migliore (long-only). Cattura la dispersione tra
crypto (gli alt forti corrono molto piu' di BTC nei bull) senza shortare nulla.
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del bar
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import get_df, available_assets, FEE_RT # noqa: E402
LEV = 3.0
GROSS = 0.45 # esposizione lorda = LEV*POS del singolo (0.15*3) per confronto equo
def build_panel(assets: list[str], tf: str) -> pd.DataFrame:
"""Matrice close allineata per timestamp (inner join)."""
closes = {}
for a in assets:
df = get_df(a, tf)
s = pd.Series(df["close"].values,
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
closes[a] = s[~s.index.duplicated()]
panel = pd.DataFrame(closes).dropna()
return panel
def simulate_rotation(panel: pd.DataFrame, lookback=30, top_k=2,
fee_rt=FEE_RT, gross=GROSS, abs_filter=True,
oos_frac=0.0) -> dict:
"""Ad ogni barra: ranking per rendimento passato `lookback`; pesi uguali sui
top_k con momentum>0 (se abs_filter); altrimenti cash. gross = esposizione tot.
oos_frac>0: parte a investire solo dall'ultimo frac del campione."""
P = panel.values
T, N = P.shape
rets = np.zeros_like(P)
rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
start = max(lookback + 1, int(T * (1 - oos_frac)) if oos_frac else lookback + 1)
cap = peak = 1000.0
max_dd = 0.0
w = np.zeros(N)
yearly: dict[int, float] = {}
turn_total = 0.0
for i in range(start, T - 1):
mom = P[i] / P[i - lookback] - 1
order = np.argsort(mom)[::-1]
new_w = np.zeros(N)
chosen = [j for j in order if (mom[j] > 0 or not abs_filter)][:top_k]
if chosen:
for j in chosen:
new_w[j] = gross / len(chosen)
# fee sul turnover (one-way = fee_rt/2 su ogni variazione di peso)
turnover = np.abs(new_w - w).sum()
cap -= cap * turnover * (fee_rt / 2)
turn_total += turnover
w = new_w
port_ret = float(np.dot(w, rets[i + 1])) # rendimento bar i->i+1
cap = max(cap * (1 + port_ret), 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
yearly[years[i]] = yearly.get(years[i], 0.0) + port_ret * 100
return {
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"turnover": turn_total,
"yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0),
"n_years": len(yearly),
}
if __name__ == "__main__":
assets = available_assets()
print(f"ROTATION cross-sectional momentum — fee {FEE_RT*100:.2f}% RT, gross {GROSS} | OOS 30%")
print(f" Paniere: {assets}")
for tf in ["1d", "4h"]:
panel = build_panel(assets, tf)
print(f"\n === {tf} === panel {panel.shape[0]} barre, {panel.index[0].date()} -> {panel.index[-1].date()}")
print(f" {'config':<22s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Turn':>7s}{'AnniP':>8s}")
for lb in [20, 30, 60, 90]:
for k in [1, 2, 3]:
full = simulate_rotation(panel, lookback=lb, top_k=k)
oos = simulate_rotation(panel, lookback=lb, top_k=k, oos_frac=0.30)
anni = f"{full['pos_years']}/{full['n_years']}"
print(f" lb{lb:<3d} top{k:<14d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['turnover']:>7.0f}{anni:>8s}")
+109
View File
@@ -0,0 +1,109 @@
"""Strategia #3 candidata: time-series momentum / trend (TSMOM).
Posizione continua decisa a close[i] dai dati passati; fee SOLO sui cambi di
posizione (poche operazioni su TF alto = fee non letali). Niente look-ahead:
il rendimento del bar i->i+1 usa la direzione decisa a close[i].
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import ema, get_df, available_assets, FEE_RT # noqa: E402
LEV = 3.0
POS = 0.15
def simulate_position(sig: np.ndarray, df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS) -> dict:
"""sig[i] in {-1,0,1} = direzione tenuta nel bar i->i+1, decisa a close[i].
Fee one-way = fee_rt/2 su ogni unita' di variazione posizione."""
c = df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
cap = peak = 1000.0
max_dd = 0.0
cur = 0.0
flips = 0
bars_in = 0
yearly: dict[int, float] = {}
for i in range(n - 1):
s = sig[i]
if not np.isfinite(s):
s = 0.0
if s != cur:
cap -= cap * pos * (fee_rt / 2) * lev * abs(s - cur)
flips += abs(s - cur) > 0
cur = s
pr = (c[i + 1] - c[i]) / c[i]
bar_ret = pos * lev * pr * cur
cap = max(cap * (1 + bar_ret), 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
if cur != 0:
bars_in += 1
yr = ts.iloc[i].year
yearly[yr] = yearly.get(yr, 0.0) + bar_ret * 100
return {
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"flips": flips,
"exposure": bars_in / n * 100,
"yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0),
"n_years": len(yearly),
}
def tsmom_signal(df, lookback=30, long_only=False):
"""+1 se close>close[-lookback], -1 (o 0 se long_only) altrimenti."""
c = df["close"].values
sig = np.zeros(len(c))
for i in range(lookback, len(c)):
up = c[i] > c[i - lookback]
sig[i] = 1.0 if up else (0.0 if long_only else -1.0)
return sig
def ema_dual_signal(df, fast=20, slow=100, long_only=False):
"""+1 se EMA_fast>EMA_slow."""
c = df["close"].values
ef, es = ema(c, fast), ema(c, slow)
sig = np.where(ef > es, 1.0, 0.0 if long_only else -1.0)
sig[:slow] = 0.0
return sig
def oos(sig, df, frac=0.30):
split = int(len(df) * (1 - frac))
s2 = sig.copy(); s2[:split] = 0.0
return simulate_position(s2, df)
def show(label, df, sig):
full = simulate_position(sig, df)
o = oos(sig, df)
anni = f"{full['pos_years']}/{full['n_years']}"
print(f" {label:<26s}{full['flips']:>6d}{full['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{anni:>8s}")
return full, o
if __name__ == "__main__":
assets = available_assets()
print(f"TSMOM / trend — fee {FEE_RT*100:.2f}% RT, leva3x pos15% | OOS30%")
for tf in ["1d", "4h"]:
print(f"\n ###### TF {tf} ######")
for a in assets:
df = get_df(a, tf)
print(f"\n === {a} {tf} === {'Flip':>5s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
show("TSMOM lb30 long/short", df, tsmom_signal(df, 30))
show("TSMOM lb30 long-only", df, tsmom_signal(df, 30, long_only=True))
show("TSMOM lb90 long/short", df, tsmom_signal(df, 90))
show("EMA 20/100 long/short", df, ema_dual_signal(df, 20, 100))
show("EMA 20/100 long-only", df, ema_dual_signal(df, 20, 100, long_only=True))
+188
View File
@@ -0,0 +1,188 @@
"""Test ingresso intra-barra: rottura banda squeeze rilevata sul 5m vs close 15m.
Domanda: entrando sul 5m appena il prezzo rompe la banda di Bollinger dello
squeeze (bande dall'ultima barra 15m CHIUSA -> nessun look-ahead), si recupera
parte del movimento che l'ingresso al close della barra 15m si perde?
Confronto a parita' di EXIT (stesso wall-clock): l'unica differenza e' il prezzo
d'ingresso (5m anticipato vs close 15m ritardato). La differenza di rendimento e'
esattamente lo "scatto" del breakout catturato in piu'.
"""
from __future__ import annotations
import sys
from pathlib import Path
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 src.live.signal_engine import keltner_ratio
OOS_START = "2023-11-20"
BB_W = 14
SQ_THR = 0.8
MIN_DUR = 5
LEV = 3.0
POS = 0.15
M15 = 15 * 60 * 1000
M5 = 5 * 60 * 1000
def build_15m_levels(df15: pd.DataFrame) -> pd.DataFrame:
c = df15["close"].values
h = df15["high"].values
l = df15["low"].values
n = len(c)
kcr = keltner_ratio(c, h, l, BB_W)
ma = np.full(n, np.nan)
sd = np.full(n, np.nan)
for t in range(BB_W, n):
w = c[t - BB_W + 1 : t + 1]
ma[t] = w.mean()
sd[t] = w.std()
upper = ma + 2 * sd
lower = ma - 2 * sd
# durata squeeze consecutiva e maturita'
dur = np.zeros(n, dtype=int)
run = 0
for t in range(n):
if not np.isnan(kcr[t]) and kcr[t] < SQ_THR:
run += 1
else:
run = 0
dur[t] = run
mature = dur >= MIN_DUR
return pd.DataFrame({
"ts15": df15["timestamp"].values,
"close_time15": df15["timestamp"].values + M15,
"close15": c,
"upper": upper,
"lower": lower,
"mature": mature,
})
def run_asset(asset: str, hold_min: int, fee_rt: float) -> dict:
df5 = load_data(asset, "5m").reset_index(drop=True)
df15 = load_data(asset, "15m").reset_index(drop=True)
lvl = build_15m_levels(df15)
d5 = pd.DataFrame({
"ts5": df5["timestamp"].values,
"close_time5": df5["timestamp"].values + M5,
"close5": df5["close"].values,
})
# banda armata: ultima barra 15m CHIUSA prima della chiusura del bar 5m
armed = pd.merge_asof(
d5.sort_values("close_time5"),
lvl[["close_time15", "upper", "lower", "mature"]].sort_values("close_time15"),
left_on="close_time5", right_on="close_time15", direction="backward",
)
# barra 15m CONTENENTE il bar 5m (per l'ingresso ritardato a close 15m)
cont = pd.merge_asof(
d5.sort_values("ts5"),
lvl[["ts15", "close15", "close_time15"]].rename(
columns={"close_time15": "cont_close_time"}).sort_values("ts15"),
left_on="ts5", right_on="ts15", direction="backward",
)
m = armed.copy()
m["cont_close"] = cont["close15"].values
m["cont_close_time"] = cont["cont_close_time"].values
oos_ms = int(pd.Timestamp(OOS_START, tz="UTC").timestamp() * 1000)
close5 = m["close5"].values
ct5 = m["close_time5"].values
upper = m["upper"].values
lower = m["lower"].values
mature = m["mature"].values
cont_close = m["cont_close"].values
cont_ct = m["cont_close_time"].values
n = len(m)
cap_e = cap_l = 1000.0 # equity ingresso early(5m) e late(15m)
peak_e = peak_l = 1000.0
dd_e = dd_l = 0.0
trades = win_e = win_l = 0
thrust_sum = 0.0
fee = fee_rt * LEV
busy_until = -1
for i in range(n):
if ct5[i] < oos_ms or ct5[i] <= busy_until:
continue
if not mature[i] or np.isnan(upper[i]):
continue
if close5[i] > upper[i]:
d = 1
elif close5[i] < lower[i]:
d = -1
else:
continue
entry_e = close5[i]
entry_l = cont_close[i]
exit_time = cont_ct[i] + hold_min * 60 * 1000
# primo close 5m al/oltre exit_time
j = np.searchsorted(ct5, exit_time, side="left")
if j >= n:
break
exit_p = close5[j]
ret_e = ((exit_p - entry_e) / entry_e) * d * LEV - fee
ret_l = ((exit_p - entry_l) / entry_l) * d * LEV - fee
thrust_sum += (entry_l - entry_e) / entry_e * d * 100 # scatto % (no leva)
cb_e, cb_l = cap_e, cap_l
cap_e = max(cb_e + cb_e * POS * ret_e, 10.0)
cap_l = max(cb_l + cb_l * POS * ret_l, 10.0)
peak_e = max(peak_e, cap_e); dd_e = max(dd_e, (peak_e - cap_e) / peak_e)
peak_l = max(peak_l, cap_l); dd_l = max(dd_l, (peak_l - cap_l) / peak_l)
trades += 1
win_e += ret_e > 0
win_l += ret_l > 0
busy_until = exit_time
return {
"trades": trades,
"avg_thrust": thrust_sum / trades if trades else 0.0,
"early_win": win_e / trades * 100 if trades else 0.0,
"late_win": win_l / trades * 100 if trades else 0.0,
"early_ret": (cap_e / 1000 - 1) * 100,
"late_ret": (cap_l / 1000 - 1) * 100,
"early_dd": dd_e * 100,
"late_dd": dd_l * 100,
}
def main():
for fee_rt in (0.002, 0.001):
print("=" * 104)
print(f" INGRESSO INTRA-BARRA 5m vs CLOSE 15m — OOS da {OOS_START} | leva={LEV:.0f}x "
f"| fee={fee_rt*100:.2f}% RT")
print(" EARLY = entra al close 5m che rompe la banda | LATE = entra al close della barra 15m | stesso exit")
print("=" * 104)
print(f" {'Asset':>5s}{'Hold':>6s}{'Trd':>6s}{'Scatto%':>9s}"
f"{'EARLY win%':>12s}{'EARLY ret%':>12s}{'LATE win%':>11s}{'LATE ret%':>11s}{'Δret%':>9s}")
print(" " + "-" * 100)
for asset in ["BTC", "ETH"]:
for hold_min in (15, 30, 45):
r = run_asset(asset, hold_min, fee_rt)
print(f" {asset:>5s}{hold_min:>5d}m{r['trades']:>6d}{r['avg_thrust']:>+9.3f}"
f"{r['early_win']:>12.1f}{r['early_ret']:>+12.1f}"
f"{r['late_win']:>11.1f}{r['late_ret']:>+11.1f}"
f"{r['early_ret']-r['late_ret']:>+9.1f}")
print(" " + "-" * 100)
print(" Scatto% = movimento medio (no leva) catturato tra rottura 5m e close 15m, nella direzione.")
print(" Δret% = vantaggio dell'ingresso anticipato. Se ~0 o negativo, il 5m non aiuta.\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,70 @@
"""LADDER RE-GATE su DATI PULITI (2026-06-18) — ri-valuta i top candidati Price Ladder dopo
clean_feed.py, con le verifiche che il critico aveva chiesto:
- gate PORT06 + corr + fee2x (su feed corretto);
- stress close_only (fill solo sul close: su dati puliti deve ~combaciare col base -> niente
dipendenza residua da spike-print);
- DD PER ANNO della griglia standalone (dov'e' la coda VERA, non l'artefatto feb-2024).
uv run python scripts/analysis/ladder_regate_clean.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.ladder_search import evaluate, regime_mask
from scripts.analysis.grid_game_gate import grid_mtm
# top candidati dal workflow (asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, regime, trend_max)
CANDS = [
("ETH", "1h", 0.16, 0.04, 4, 0.12, 0.05, 720, "range", 2.0), # raccomandato dalla sintesi
("ETH", "1h", 0.16, 0.06, 4, 0.12, 0.05, 720, "range", 2.0), # corr piu' bassa (0.249)
("ETH", "30m", 0.16, 0.04, 3, 0.12, 0.05, 1440, "range", 2.0),
("BTC", "1h", 0.08, 0.06, 3, 0.12, 0.05, 720, "none", 2.0), # best OOS; DD era 53.69% (artefatto)
("BTC", "1h", 0.20, 0.06, 6, 0.12, 0.05, 720, "range", 1.5), # corr piu' bassa (0.161)
("BTC", "30m", 0.08, 0.06, 3, 0.12, 0.05, 1440, "none", 2.0),
]
def peryear_dd(asset, tf, rd, ru, lv, sl, tp, mb, regime, tmax):
mask = regime_mask(asset, tf, trend_max=tmax) if regime == "range" else None
eqd, _ = grid_mtm(asset, tf=tf, range_down=rd, range_up=ru, levels=lv,
sl_buf=sl, tp_buf=tp, max_bars=mb, deploy_mask=mask)
out = {}
for y, g in eqd.groupby(eqd.index.year):
peak = g.cummax()
out[int(y)] = round(float(((g - peak) / peak).min() * 100), 1)
return out
def main():
print("RE-GATE PRICE LADDER su dati PULITI — gate PORT06 + close_only stress + DD/anno\n")
print(f"{'candidato':<34}{'fullDD':>7}{'oos_sh':>7}{'corr':>6}{'fee2x':>6}"
f"{'co_oos':>7}{'verdHALF':>10} DD per anno")
for c in CANDS:
asset, tf, rd, ru, lv, sl, tp, mb, regime, tmax = c
r = evaluate(asset, tf, rd, ru, lv, sl, tp, mb, regime=regime, trend_max=tmax)
rc = evaluate(asset, tf, rd, ru, lv, sl, tp, mb, regime=regime, trend_max=tmax,
close_only=True, do_gate=False, do_fee2x=False)
pyd = peryear_dd(*c)
tag = f"{asset} {tf} rd{rd} ru{ru} L{lv} {regime}"
py = " ".join(f"{y}:{v}" for y, v in pyd.items())
print(f"{tag:<34}{r['full_dd']:>7.1f}{r['oos_sh']:>7.2f}{r['max_corr_existing']:>6.2f}"
f"{r['fee2x_oos_sh']:>6.2f}{rc['oos_sh']:>7.2f}{r['verdict_half']:>10} {py}")
print("\n fullDD = DD standalone sulla finestra del GATE (IDX 2021+); era ~54% su BTC")
print(" PRIMA della pulizia (artefatto spike-print 2024, ora sparito).")
print(" ATTENZIONE — DD per anno: il TAIL VERO e' il 2018 (-44/-52%), che il gate (IDX")
print(" 2021-01-01+) NON VEDE. Una griglia long-only sarebbe stata sventrata nel")
print(" bear 2018. Il regime-gate piu' stretto (BTC rd0.2 L6 range1.5) lo dimezza (-27.7%).")
print(" co_oos = OOS Sharpe con fill SOLO sul close (no wick). CROLLA (4.7->0.2): l'edge della")
print(" griglia viene dai fill INTRABAR ai livelli. Per ordini LIMIT i fill intrabar")
print(" sono LEGITTIMI (close_only e' troppo severo), ma il gap segnala sensibilita'")
print(" all'ipotesi di fill -> serve il ledger shadow reale prima di fidarsi.")
print(" verdHALF = gate PORT06 a half-size | corr = max corr coi 19 sleeve (ridondanza)")
if __name__ == "__main__":
main()
+191
View File
@@ -0,0 +1,191 @@
"""LADDER SEARCH — harness per la caccia multi-agente a strategie Price Ladder (griglia).
Goal 2026-06-18 (branch price_ladder_research): "decine di agenti a cercare strategie
Price Ladder". CONTESTO: il gioco "Grid Traders" trovo' gia' una griglia ETH asimmetrica
fortissima standalone (FULL Sharpe 5.61, OOS 4.21, plateau 16/16) ma BOCCIATA al gate
PORT06 -- ridondante con le fade ETH (corr +0.40 con MR02_ETH): full-size alza FULL ma
abbassa OOS 10.86->10.47. Quindi la ricerca NON e' "trovare un edge griglia" (gia' fatto)
ma trovarne uno che PASSI IL GATE = aggiunga DIVERSIFICAZIONE. Leve nuove:
- ASSET diverso da ETH (BTC: meno ridondante con la reversione ETH);
- REGIME-GATE: deployare la griglia SOLO in regime di range (non trend) -- il doc
STRATEGIA_GRIGLIA.md dice che la griglia muore in trend; gateare i deploy concentra
l'edge dove funziona E riduce la correlazione coi trend-follower;
- STRUTTURA: livelli, range asimmetrico, sl/tp buffer, max_bars, tf.
Motore = grid_mtm (mark-to-market ONESTO: SL gap-aware, flat-skip, fee 0.10% RT) di
grid_game_gate.py, esteso con deploy_mask per il regime-gate (retro-compatibile).
Tutto NETTO, OOS held-out, leva 3x. Il giudizio che CONTA e' il gate PORT06.
CLI (JSON):
uv run python scripts/analysis/ladder_search.py eval ETH 15m 0.171 0.046 4 0.124 0.048 2143
uv run python scripts/analysis/ladder_search.py eval BTC 1h 0.13 0.05 4 0.12 0.05 1500 range 2.0
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.grid_game_gate import grid_mtm, std, _load
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, IDX
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
_BASE = None
def _baseline():
global _BASE
if _BASE is None:
from src.portfolio.sleeves import all_sleeve_equities
_BASE = dict(all_sleeve_equities())
return _BASE
def _atr(df, n=14):
h, l, c = df["high"].to_numpy(float), df["low"].to_numpy(float), df["close"].to_numpy(float)
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().to_numpy()
def regime_mask(asset, tf, ema_n=200, trend_max=2.0):
"""Mask CAUSALE 'range-bound' allineata alle righe di _load(asset,tf):
True dove |close - EMA(ema_n)| / ATR(14) < trend_max (prezzo vicino al trend =
regime di range -> la griglia puo' deployare). Decisione a close[j] con dati <= j."""
df = _load(asset, tf)
c = df["close"].to_numpy(float)
ema = pd.Series(c).ewm(span=ema_n, adjust=False).mean().to_numpy()
a = _atr(df, 14)
with np.errstate(invalid="ignore", divide="ignore"):
dist = np.abs(c - ema) / np.where(a == 0, np.nan, a)
m = dist < trend_max
m[~np.isfinite(dist)] = False # warmup / ATR nan -> niente deploy
return m
def _gate(grid_eq):
"""Gate PORT06 (stesso metodo di grid_game_gate): baseline vs +LADDER full/half.
Ritorna metriche base/full/half + max corr coi 19 sleeve (segnale ridondanza)."""
p = PORTFOLIOS["PORT06"]
base = _baseline()
def port_m(extra):
members = dict(base); ids = list(p.sleeve_ids)
if extra is not None:
members["LADDER"] = extra; ids = ids + ["LADDER"]
dr = pd.DataFrame({i: members[i].reindex(IDX).ffill().bfill()
.pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i].reindex(IDX).ffill().bfill() for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
fb, ob = port_m(None)
gr = grid_eq.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
maxcorr = max(abs(gr.corr(e.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)))
for e in base.values())
half = (1 + 0.5 * gr).cumprod()
ff, of = port_m(grid_eq)
fh, oh = port_m(half)
def ok(f, o):
return bool(o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9
and f["sharpe"] >= fb["sharpe"] - 0.02)
return {
"base_full_sh": round(fb["sharpe"], 2), "base_full_dd": round(fb["dd"], 2),
"base_oos_sh": round(ob["sharpe"], 2), "base_oos_dd": round(ob["dd"], 2),
"full_oos_sh": round(of["sharpe"], 2), "full_oos_dd": round(of["dd"], 2),
"full_full_sh": round(ff["sharpe"], 2), "full_full_dd": round(ff["dd"], 2),
"half_oos_sh": round(oh["sharpe"], 2), "half_oos_dd": round(oh["dd"], 2),
"half_full_sh": round(fh["sharpe"], 2), "half_full_dd": round(fh["dd"], 2),
"max_corr_existing": round(float(maxcorr), 3),
"verdict_full": "PROMOSSO" if ok(ff, of) else "bocciato",
"verdict_half": "PROMOSSO" if ok(fh, oh) else "bocciato",
}
def evaluate(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars,
regime="none", trend_max=2.0, ema_n=200, do_gate=True, do_fee2x=True,
flat_skip=True, close_only=False) -> dict:
mask = regime_mask(asset, tf, ema_n=ema_n, trend_max=trend_max) if regime == "range" else None
cfg = dict(tf=tf, range_down=rd, range_up=ru, levels=levels,
sl_buf=sl_buf, tp_buf=tp_buf, max_bars=max_bars)
try:
eqd, st = grid_mtm(asset, **cfg, deploy_mask=mask, flat_skip=flat_skip, close_only=close_only)
except ValueError as e:
return {"asset": asset, "tf": tf, "regime": regime, "error": str(e)}
f, o = std(eqd)
row = {
"asset": asset, "tf": tf, "regime": regime, "trend_max": trend_max,
"rd": rd, "ru": ru, "levels": levels, "sl_buf": sl_buf, "tp_buf": tp_buf, "max_bars": max_bars,
"trades": st["trades"], "win": round(st["win"], 1), "stops": st["stops"],
"full_sh": round(f["sharpe"], 2), "full_dd": round(f["dd"], 2), "full_ret": round(f["ret"], 0),
"oos_sh": round(o["sharpe"], 2), "oos_dd": round(o["dd"], 2),
}
if do_fee2x:
eq2, _ = grid_mtm(asset, **cfg, fee_side=0.001, deploy_mask=mask, flat_skip=flat_skip)
row["fee2x_oos_sh"] = round(std(eq2)[1]["sharpe"], 2)
if do_gate:
row.update(_gate(eqd))
return row
# griglia di struttura coarse per lo scan (range asimmetrico favorito, come il vincitore)
SCAN_RD = [0.08, 0.12, 0.16, 0.20]
SCAN_RU = [0.04, 0.06]
SCAN_LEVELS = [3, 4, 6]
SCAN_SLB = [0.12]
SCAN_TP = 0.05
MAXBARS_TF = {"15m": 2880, "30m": 1440, "1h": 720} # ~30 giorni di episodio
def scan(asset, tf, regime="none", trend_max=2.0, top=10) -> list:
"""Sweep coarse della struttura (rd x ru x levels) col gate PORT06, baseline
cachata (una load per processo). Ritorna le top-`top` celle per qualita' di gate."""
mb = MAXBARS_TF.get(tf, 720)
rows = []
for rd in SCAN_RD:
for ru in SCAN_RU:
for lv in SCAN_LEVELS:
for slb in SCAN_SLB:
r = evaluate(asset, tf, rd, ru, lv, slb, SCAN_TP, mb,
regime=regime, trend_max=trend_max,
do_gate=True, do_fee2x=False)
if "error" not in r:
rows.append(r)
# score: PROMOSSO half/full premiati; poi OOS migliorato col candidato; penalita' FULL DD del portafoglio
def score(r):
promo = (r.get("verdict_half") == "PROMOSSO") + (r.get("verdict_full") == "PROMOSSO")
return (promo, r.get("half_oos_sh", 0) - 0.1 * r.get("full_full_dd", 99))
rows.sort(key=score, reverse=True)
return rows[:top]
def main():
a = sys.argv
if len(a) >= 2 and a[1] == "scan":
asset, tf = a[2], a[3]
regime = a[4] if len(a) > 4 else "none"
trend_max = float(a[5]) if len(a) > 5 else 2.0
print(json.dumps(scan(asset, tf, regime=regime, trend_max=trend_max)))
return
if len(a) < 2 or a[1] != "eval":
print(__doc__); return
asset, tf = a[2], a[3]
rd, ru = float(a[4]), float(a[5])
levels = int(a[6]); sl_buf, tp_buf = float(a[7]), float(a[8]); max_bars = int(a[9])
regime = a[10] if len(a) > 10 else "none"
trend_max = float(a[11]) if len(a) > 11 else 2.0
print(json.dumps(evaluate(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars,
regime=regime, trend_max=trend_max)))
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
"""LADDER SL/TP STUDY (2026-06-18) — i 3 passi pre-deploy + studio di SL e TP da aggiungere.
Contesto: dopo clean_feed.py i Price Ladder BTC/ETH sono candidati VERI (PROMOSSO, DD gate
2021+ ~11-15%), MA il tail REALE e' il 2018 (-44/-52%) che il gate (IDX 2021+) NON vede. SL/TP
sono la leva per domarlo. Prior del progetto: gli stop su mean-reversion sono falsi negativi
(EXIT-16/SH01/pairs z-stop) -> ma un grid in un BEAR sostenuto (2018, niente rimbalzo) e' il
caso in cui un catastrophe-SL genuinamente aiuta. Questo studio distingue i due regimi.
Fa i 3 passi:
1. VALUTAZIONE 2018-INCLUSIVE: metriche standalone su TUTTA la storia (2018+) + DD per anno
(il gate del progetto e' cieco al 2018; qui no).
2. FILL maker vs taker: il grid e' LIMIT -> su Deribit fill MAKER ~0%; confronto 0% vs 0.10%
RT (la harness e' conservativa). E' la preparazione backtest dello shadow ledger (la parte
live = deploy operativo a parte).
3. HALF-SIZE: il candidato finale a meta' size (prudenza coda).
E studia SL (sl_buf, = catastrophe stop sotto il range) x TP (tp_buf) sul tail 2018 vs edge 2021+.
uv run python scripts/analysis/ladder_sltp_study.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.ladder_search import regime_mask, _gate
from scripts.analysis.grid_game_gate import grid_mtm
OOS_DATE = pd.Timestamp("2024-10-12", tz="UTC")
def fm(eqd: pd.Series) -> dict:
"""Metriche su TUTTA la storia (2018+), niente reindex a IDX 2021+."""
def sh(s):
r = s.pct_change().fillna(0.0)
return float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
def dd(s):
c = s / s.iloc[0]
return float(((c - c.cummax()) / c.cummax()).min() * 100)
oos = eqd[eqd.index >= OOS_DATE]
peryear = {int(y): round(dd(g), 1) for y, g in eqd.groupby(eqd.index.year)}
return {"full_sh": round(sh(eqd), 2), "full_dd": round(dd(eqd), 1),
"oos_sh": round(sh(oos), 2) if len(oos) > 5 else 0.0,
"dd2018": peryear.get(2018, 0.0), "dd2022": peryear.get(2022, 0.0),
"peryear": peryear}
def run(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, regime, tmax, fee_side=0.0005):
mask = regime_mask(asset, tf, trend_max=tmax) if regime == "range" else None
eqd, st = grid_mtm(asset, tf=tf, range_down=rd, range_up=ru, levels=levels,
sl_buf=sl_buf, tp_buf=tp_buf, max_bars=max_bars,
deploy_mask=mask, fee_side=fee_side)
m = fm(eqd); m["trades"] = st["trades"]; m["eqd"] = eqd
return m
# candidati base (i migliori del re-gate pulito)
BASES = {
"BTC 1h L6 range1.5": dict(asset="BTC", tf="1h", rd=0.20, ru=0.06, levels=6,
max_bars=720, regime="range", tmax=1.5),
"BTC 1h L3 none": dict(asset="BTC", tf="1h", rd=0.08, ru=0.06, levels=3,
max_bars=720, regime="none", tmax=2.0),
}
def sltp_sweep(name, base):
print(f"\n{'='*104}\n SL/TP SWEEP — {name} (full=2018+, dd2018=tail vero, oos=2024-10+; fee 0.10% RT)\n{'='*104}")
print(f" {'sl_buf':>7}{'tp_buf':>7}{'trades':>8}{'full_sh':>9}{'full_dd':>9}{'dd2018':>8}{'dd2022':>8}{'oos_sh':>8}")
best = None
for slb in (0.06, 0.08, 0.10, 0.12, 0.15, 0.20):
for tpb in (0.03, 0.05, 0.08):
m = run(**base, sl_buf=slb, tp_buf=tpb)
star = ""
# criterio: tail 2018 contenuto (>-25%) E oos edge preservato (sh>3) E full edge ok
if m["dd2018"] > -25 and m["oos_sh"] > 3 and m["full_sh"] > 1.5:
star = " <-- tail-capped + edge"
if best is None or m["dd2018"] > best[1]["dd2018"]:
best = ((slb, tpb), m)
print(f" {slb:>7.2f}{tpb:>7.2f}{m['trades']:>8}{m['full_sh']:>9.2f}"
f"{m['full_dd']:>9.1f}{m['dd2018']:>8.1f}{m['dd2022']:>8.1f}{m['oos_sh']:>8.2f}{star}")
return best
def main():
print("LADDER SL/TP STUDY — 3 passi pre-deploy + SL/TP da aggiungere\n")
# passo 1: valutazione 2018-inclusive dei due base (sl/tp correnti)
print("[1] VALUTAZIONE 2018-INCLUSIVE (sl/tp correnti) — DD per anno (il gate IDX2021+ e' cieco al 2018):")
for name, base in BASES.items():
m = run(**base, sl_buf=0.12, tp_buf=0.05)
print(f" {name:<22} full_sh {m['full_sh']:>5.2f} full_dd {m['full_dd']:>6.1f} "
f"oos_sh {m['oos_sh']:>5.2f} | DD/anno {m['peryear']}")
# passo SL/TP: sweep su entrambi
winners = {}
for name, base in BASES.items():
winners[name] = sltp_sweep(name, base)
# passo 2+3: maker vs taker + half-size + gate 2021+, sul miglior (sl,tp) del candidato regime-gated
name = "BTC 1h L6 range1.5"
w = winners.get(name)
print(f"\n{'='*104}\n [2+3] FILL maker/taker + HALF-SIZE + GATE 2021+ — {name}\n{'='*104}")
if w is None:
print(" nessun (sl,tp) ha cappato il tail 2018 sotto -25% mantenendo l'edge: vedi sweep sopra.")
# usa comunque lo sl piu' stretto per il confronto fill
(slb, tpb) = (0.06, 0.05)
else:
(slb, tpb), _ = w
print(f" miglior (sl_buf,tp_buf) per tail 2018 + edge = ({slb}, {tpb})")
base = BASES[name]
for fee, lab in ((0.0005, "taker 0.10% RT"), (0.0, "maker 0% (Deribit limit)")):
m = run(**base, sl_buf=slb, tp_buf=tpb, fee_side=fee)
g = _gate(m["eqd"])
print(f" fee={lab:<26} oos_sh {m['oos_sh']:>5.2f} dd2018 {m['dd2018']:>6.1f} "
f"gate½ {g['verdict_half']} (OOS {g['base_oos_sh']}->{g['half_oos_sh']}, corr {g['max_corr_existing']})")
print("\n NB half-size: il gate 'half' E' gia' a meta' size (vedi grid_game_gate). La coda 2018")
print(" standalone va dimezzata sul book a half. Lo shadow ledger reale (fill intrabar/maker)")
print(" resta il passo OPERATIVO finale, non backtestabile qui.")
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
"""Report ricorrente LEDGER REALE vs BACKTEST — il gate per scalare il capitale.
Per gli sleeve ESEGUITI (6 fade 15m, DIP01, 6 pairs, SH01) il sim del worker ==
backtest canonico PER COSTRUZIONE (validato: validate_worker_pairs, parity test).
Quindi "reale vs backtest" = "reale vs sim" = la **fuga di esecuzione**: slippage
sugli ingressi/uscite + fee reali vs assunte + effetti netting/phantom/sim_fallback.
È il numero che dice se l'edge SOPRAVVIVE ai fill veri — la condizione per passare
da testnet/piccolo a capitale serio.
Cosa misura (finestra mobile, default 7g) leggendo SOLO i trades.jsonl + status.json
(nessuna rete → affidabile in cron):
- PnL realizzato sim vs reale (Σ e per-trade) -> LEAKAGE € e % (la bottom line)
- slippage ingressi (REAL_OPEN.slippage_bps) e uscite (REAL_CLOSE.slippage_bps)
- fee reali vs assunte (0.10% RT)
- trade sim_fallback (reale mai eseguito/fillato) = quota NON coperta dal reale
- ledger per-sleeve: real_capital vs capital (sim)
Verdetto: leakage per-trade piccolo e stabile -> verde (si puo' pensare a scalare).
uv run python scripts/analysis/ledger_vs_backtest.py # stampa
uv run python scripts/analysis/ledger_vs_backtest.py --days 14 # finestra 14g
uv run python scripts/analysis/ledger_vs_backtest.py --telegram # + invio Telegram
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from statistics import median
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
ASSUMED_FEE_RT = 0.001 # 0.10% RT assunto dal backtest
GREEN_BPS, YELLOW_BPS = 15.0, 40.0 # soglie slippage medio per-lato (verdetto)
def _parse_ts(s: str) -> datetime | None:
try:
t = datetime.fromisoformat(s.replace("Z", "+00:00"))
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
except Exception:
return None
def collect(days: int, since: datetime | None = None) -> dict:
# since (clean-start) ha priorita' sulla finestra mobile: lo scheduler parte
# dal 2026-06-13 (post-fix TP_PHANTOM/netting/ribilancio) cosi' accumula SOLO
# dati puliti; una finestra mobile pura includerebbe l'incidente testnet pre-fix
# (sim +82 vs reale +5: +4% fantasma che il sim bookava e il reale no).
cut = since or (datetime.now(timezone.utc) - timedelta(days=days))
entries, exits, closes = [], [], []
for f in sorted(PAPER.glob("*/trades.jsonl")):
wid = f.parent.name
for line in f.read_text().splitlines():
try:
e = json.loads(line)
except Exception:
continue
tt = _parse_ts(e.get("ts", ""))
if tt is None or tt < cut:
continue
ev = e.get("event")
if ev == "REAL_OPEN":
entries.append((wid, e))
elif ev in ("REAL_CLOSE", "REAL_CLOSE_PAIR"):
exits.append((wid, e))
elif ev == "CLOSE":
closes.append((wid, e))
return {"entries": entries, "exits": exits, "closes": closes}
def _stats(vals: list[float]) -> dict:
if not vals:
return {"n": 0, "mean": 0.0, "med": 0.0, "p90": 0.0, "max": 0.0}
s = sorted(vals)
return {"n": len(s), "mean": sum(s) / len(s), "med": median(s),
"p90": s[min(len(s) - 1, int(0.9 * len(s)))], "max": s[-1]}
def analyze(days: int, since: datetime | None = None) -> dict:
d = collect(days, since)
# slippage ingressi/uscite (bps); fee reali ingresso
open_slip = [abs(e.get("slippage_bps") or 0.0) for _, e in d["entries"]]
exit_slip = [abs(e.get("slippage_bps") or 0.0) for _, e in d["exits"]
if e.get("real_fill") is not None] # null = uscita da TP resting (no slip)
open_fee = [e.get("fee_usd") or 0.0 for _, e in d["entries"]]
# PnL realizzato sim vs reale dai CLOSE (la verita' contabile guidata da real_truth)
real_closes = [e for _, e in d["closes"] if e.get("pnl_source") == "real"]
fallback = [e for _, e in d["closes"] if e.get("pnl_source") == "sim_fallback"]
sim_sum = sum(e.get("sim_pnl") or 0.0 for e in real_closes)
real_sum = sum(e.get("real_pnl") or 0.0 for e in real_closes)
per_trade_gap = [(e.get("sim_pnl") or 0.0) - (e.get("real_pnl") or 0.0) for e in real_closes]
# ledger per-sleeve: real vs sim (solo worker eseguiti = con REAL_OPEN nella storia)
executed = {wid for wid, _ in d["entries"]}
ledger = []
for wid in sorted(executed):
sp = PAPER / wid / "status.json"
if not sp.exists():
continue
st = json.loads(sp.read_text())
cap, rc = st.get("capital"), st.get("real_capital")
if cap is not None and rc is not None:
ledger.append((wid, cap, rc, rc - cap))
return {"days": days, "since": since,
"open_slip": _stats(open_slip), "exit_slip": _stats(exit_slip),
"open_fee_mean": (sum(open_fee) / len(open_fee)) if open_fee else 0.0,
"n_real": len(real_closes), "n_fallback": len(fallback),
"sim_sum": sim_sum, "real_sum": real_sum,
"leak_total": sim_sum - real_sum,
"leak_per_trade": (sum(per_trade_gap) / len(per_trade_gap)) if per_trade_gap else 0.0,
"ledger": ledger}
def verdict(a: dict) -> tuple[str, str]:
avg_slip = (a["open_slip"]["mean"] + a["exit_slip"]["mean"]) / 2
if a["n_real"] < 10:
return "🟡", f"campione PICCOLO ({a['n_real']} trade reali): non concludere ancora"
if avg_slip <= GREEN_BPS and abs(a["leak_per_trade"]) < 0.30:
return "🟢", "leakage basso e stabile: reale ~ backtest"
if avg_slip <= YELLOW_BPS:
return "🟡", "leakage moderato: tenere d'occhio prima di scalare"
return "🔴", "leakage ALTO: l'edge si erode sull'esecuzione, NON scalare"
def render(a: dict) -> str:
flag, msg = verdict(a)
win = f"da {a['since']:%Y-%m-%d}" if a.get("since") else f"finestra {a['days']}g"
L = [f"LEDGER REALE vs BACKTEST — {win} {flag}",
f" {msg}",
f" trade reali: {a['n_real']} | sim_fallback (reale mai fillato): {a['n_fallback']}",
f" PnL realizzato: sim {a['sim_sum']:+.2f} reale {a['real_sum']:+.2f} "
f"-> LEAKAGE {a['leak_total']:+.2f} ({a['leak_per_trade']:+.3f}/trade)",
f" slippage ingressi (bps): media {a['open_slip']['mean']:.1f} "
f"med {a['open_slip']['med']:.1f} p90 {a['open_slip']['p90']:.1f} max {a['open_slip']['max']:.1f} "
f"(n={a['open_slip']['n']})",
f" slippage uscite (bps): media {a['exit_slip']['mean']:.1f} "
f"med {a['exit_slip']['med']:.1f} max {a['exit_slip']['max']:.1f} (n={a['exit_slip']['n']}; "
f"escluse uscite da TP resting)",
f" fee reale media ingresso: ${a['open_fee_mean']:.4f}"]
if a["ledger"]:
L.append(" ledger per-sleeve (sim -> reale, Δ):")
for wid, cap, rc, dlt in a["ledger"]:
short = wid.replace("_reversion", "").replace("_fade", "").replace("_bollinger", "")[:30]
L.append(f" {short:30} {cap:7.2f} -> {rc:7.2f} {dlt:+.2f}")
return "\n".join(L)
def main() -> int:
days = 7
since = None
if "--days" in sys.argv:
days = int(sys.argv[sys.argv.index("--days") + 1])
if "--since" in sys.argv:
since = _parse_ts(sys.argv[sys.argv.index("--since") + 1] + "T00:00:00+00:00")
a = analyze(days, since)
text = render(a)
print(text)
if "--telegram" in sys.argv:
from src.live.telegram_notifier import send_telegram
from src.version import APP_VERSION
send_telegram(f"📒 <b>LEDGER vs BACKTEST</b> <code>v{APP_VERSION}</code>\n<pre>{text}</pre>")
print("[telegram] report inviato")
flag, _ = verdict(a)
return 0 if flag == "🟢" else 1
if __name__ == "__main__":
sys.exit(main())
+244
View File
@@ -0,0 +1,244 @@
"""LEVERAGE SWEEP — cosa succede a PORT06 con leva 1..10 nei vari scenari (2026-06-18).
Domanda utente: "se porto a leva 10 cosa succede?". Estende lev_frontier() di
accel50_research.py da 1-6 a 1-10 e su PIU' scenari, e soprattutto aggiunge le
NON-LINEARITA' che il modello daily-lineare NASCONDE e che mordono a leva alta:
1) DRAWDOWN: nel modello daily scala ~lineare con la leva (DD_L ~ DD_1 * L) fino
alla rovina. Ma e' un DD su daily AGGREGATI: nasconde le escursioni intraday.
2) VOLATILITY DRAG: la crescita GEOMETRICA (CAGR) e' concava nella leva: cresce,
ha un massimo (~leva di Kelly), poi CROLLA. Oltre Kelly piu' leva = MENO ritorno
E piu' rischio. Con questa serie ad altissimo Sharpe il picco e' lontano -> il
modello fa sembrare la leva "gratis": e' l'illusione da smontare.
3) ROVINA / LIQUIDAZIONE: equity non puo' andare sotto zero. A leva L un ritorno di
periodo r da' (1 + L*r); se L*r <= -1 -> conto azzerato. Il modello daily lo
vede solo sul worst-day aggregato (docile); la realta' e' INTRADAY (gap, flash
crash, maintenance-margin Deribit) -> la leva di rovina REALE e' molto piu' bassa.
Modello base: scala lineare dei daily return canonici (== live, parita' validata),
`base` corrisponde a leva 2 (come in accel50). Per leva L: r_L = base * (L/2).
uv run python scripts/analysis/leverage_sweep.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
def _load_base() -> tuple[pd.Series, int]:
"""Daily return PORT06 canonici (== live a leva 2) + indice di split OOS."""
from scripts.portfolios._defs import PORTFOLIOS
from scripts.analysis.combine_portfolio import port_returns, SPLIT
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
p = PORTFOLIOS["PORT06"]
eq = all_sleeve_equities()
members = {sid: eq[sid] for sid in p.sleeve_ids}
w = p.weight_vector(sleeve_returns_df(p.sleeve_ids))
return port_returns(members, w), SPLIT
def _metrics(r: pd.Series) -> dict:
"""CAGR geometrica, maxDD, Sharpe, worst-day, equity finale e flag rovina.
Clip a -100%/giorno: oltre, il conto e' azzerato (rovina) e resta a zero."""
ruined = bool((r <= -1.0).any())
rc = r.clip(lower=-1.0) # un -100% azzera; non si recupera da zero
curve = (1 + rc).cumprod()
if ruined: # congela a zero dal primo azzeramento
first = int(np.argmax(rc.values <= -1.0))
curve.iloc[first:] = 0.0
years = len(r) / 365.0
final = float(curve.iloc[-1])
cagr = (final ** (1 / years) - 1) * 100 if final > 0 else -100.0
peak = curve.cummax()
dd = float(((curve - peak) / peak).min() * 100)
sharpe = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
simple = (final - 1) * 100 if final > 0 else -100.0 # ritorno totale intra-periodo
return {"cagr": cagr, "cagr_simple": simple, "dd": dd, "sharpe": sharpe,
"worst": float(r.min() * 100), "final": final, "ruined": ruined}
def sweep(base: pd.Series, split: int) -> None:
full, oos = base, base.iloc[split:]
print(f"PORT06 daily — FULL {base.index[0].date()}{base.index[-1].date()} "
f"({len(full)}g) | OOS {base.index[split].date()}→ ({len(oos)}g, regime CALMO)")
print(f" worst-day base(leva2) = {base.min()*100:+.2f}% | Sharpe leva-invariante "
f"(modello lineare): FULL {_metrics(full).get('sharpe'):.2f} / OOS {_metrics(oos).get('sharpe'):.2f}")
print()
hdr = ("leva CAGR_full% DD_full% x_full CAGR_oos% DD_oos% "
"worstday% anni→€50/g(2k) rovina?")
print(hdr); print("-" * len(hdr))
for L in range(1, 11):
f = L / 2.0
mf, mo = _metrics(full * f), _metrics(oos * f)
# anni a €50/g da €2000, su CAGR OOS geometrica (come accel50)
co = mo["cagr"]
if co > 0 and mo["final"] > 0:
k = 2020 * (50 / (2020 * ((1 + co/100) ** (1/365) - 1))) # capitale per €50/g
anni = np.log(k / 2020) / np.log(1 + co / 100)
anni_s = f"{max(anni,0):.1f}"
else:
anni_s = ""
flag = "💀 RUIN" if (mf["ruined"] or mo["ruined"]) else ("⚠ alto" if mf["dd"] < -25 else "ok")
print(f"{L:>3} {mf['cagr']:>9.0f} {mf['dd']:>8.1f} {mf['final']:>7.1f}x "
f"{mo['cagr']:>9.0f} {mo['dd']:>8.1f} {mf['worst']:>8.2f} "
f"{anni_s:>12} {flag}")
def kelly_and_ruin(base: pd.Series, split: int) -> None:
"""Leva di Kelly (picco crescita geometrica) e leve di rovina sotto shock realistici."""
print("\n=== NON-LINEARITA' che il modello daily nasconde ===\n")
# Kelly: f* (in unita' di leva) che massimizza E[log(1+ (L/2)*base)]
grid = np.linspace(0.1, 60, 600)
def glog(L, r):
rr = (r * (L / 2.0)).clip(lower=-0.999)
return np.mean(np.log1p(rr))
for label, r in [("FULL", base), ("OOS-calmo", base.iloc[split:])]:
gl = [glog(L, r) for L in grid]
kstar = grid[int(np.argmax(gl))]
print(f" Kelly {label:9}: leva ottimale ~{kstar:.0f}x "
f"(oltre, piu' leva = MENO crescita geometrica). "
f"Half-Kelly prudente ~{kstar/2:.0f}x")
print("\n -> Sharpe altissimo + regime OOS calmo spingono Kelly a leve assurde:")
print(" e' l'ARTEFATTO del backtest, NON un via libera. Il drag morde tardi.")
print("\n=== ROVINA / LIQUIDAZIONE (il rischio VERO, intraday) ===")
mday = base.min() / 2.0 # worst-day a leva 1
print(f" worst-DAY storico (leva1) = {mday*100:+.2f}% -> rovina daily a leva "
f"{-1/mday:.0f}x (irrealistico: aggregato/diversificato)")
# shock INTRADAY realistici sull'esposizione NETTA del book reale
# 7 sleeve equal-weight, position_size 0.5 -> esposizione lorda per leva = L*0.5
# (frazione del capitale a mercato); un crash correlato colpisce quella frazione.
ps = 0.5
print(f" Config live: 7 sleeve EW, position_size={ps} -> esposizione ~ leva×{ps} "
f"del capitale (a leva 3 = 1.5x; a leva 10 = 5x).")
for shock in (0.10, 0.20, 0.40):
Lruin = 1.0 / (shock * ps)
print(f" crash correlato intraday {shock*100:>4.0f}% sull'esposizione: "
f"azzera il conto a leva ~{Lruin:.0f}x "
f"(disaster-SL on-book a -30%/pos mitiga ma NON in un gap-through)")
print("\n NB: il backtest e' su daily AGGREGATI di un paniere con stop in regime "
"calmo. Sottostima la coda intraday, i gap, lo slippage a size grande e la "
"maintenance-margin Deribit (che liquida PRIMA del -100% del modello).")
def per_year(base: pd.Series) -> None:
"""Sweep leva 1→10 ANNO PER ANNO: la media FULL nasconde l'anno peggiore.
Per ogni anno civile: ritorno geometrico e maxDD INTRA-anno ad ogni leva."""
years = sorted({d.year for d in base.index})
levs = list(range(1, 11))
print("\n=== ANNO PER ANNO (ritorno % geometrico intra-anno) ===")
print("anno g wDay@L1 " + "".join(f"L{L:>2} " for L in levs))
print("-" * (24 + 9 * len(levs)))
worst_dd = {}
for y in years:
r = base[base.index.year == y]
cells = []
for L in levs:
m = _metrics(r * (L / 2.0))
cells.append("RUIN" if m["ruined"] else f"{m['cagr_simple']:+.0f}")
worst_dd.setdefault(L, []).append((y, _metrics(r * (L / 2.0))["dd"]))
wd = r.min() / 2.0 * 100
tag = " <calmo" if y >= 2025 else ""
print(f"{y} {len(r):>3} {wd:>6.2f}% " + "".join(f"{c:>7} " for c in cells) + tag)
print("\n=== ANNO PER ANNO (maxDD % intra-anno) ===")
print("anno g wDay@L1 " + "".join(f"L{L:>2} " for L in levs))
print("-" * (24 + 9 * len(levs)))
for y in years:
r = base[base.index.year == y]
cells = []
for L in levs:
m = _metrics(r * (L / 2.0))
cells.append("RUIN" if m["ruined"] else f"{m['dd']:.1f}")
wd = r.min() / 2.0 * 100
tag = " <calmo" if y >= 2025 else ""
print(f"{y} {len(r):>3} {wd:>6.2f}% " + "".join(f"{c:>7} " for c in cells) + tag)
# anno peggiore per DD ad ogni leva
print("\n=== ANNO PEGGIORE per drawdown, ad ogni leva ===")
for L in levs:
yw, ddw = min(worst_dd[L], key=lambda t: t[1])
print(f" leva {L:>2}: worst-anno {yw} maxDD {ddw:.1f}%"
+ (" 💀 RUIN in qualche anno" if any(_metrics(base[base.index.year == yy] * (L/2.0))["ruined"] for yy in years) else ""))
def shock_2022() -> None:
"""STRESS con gli shock REALI 2022 (LUNA/3AC/FTX) sull'esposizione netta del book.
Smonta il fill-al-livello del backtest daily: le fade+DIP01 COMPRANO i dip -> in un
crollo sono net-LONG (catturate dalla parte sbagliata). Perdita = netta×shock; rovina
a L_ruin = 1/(beta·ps·shock). I dati sono BTC/ETH 1h storici nostri, anno 2022."""
from src.data.downloader import load_data
print("\n=== STRESS SHOCK REALE 2022 (BTC/ETH storici, fill al GAP non al livello) ===")
res = {}
for a in ("BTC", "ETH"):
df = load_data(a, "1h").set_index("datetime")
d = df[(df.index >= "2022-01-01") & (df.index < "2023-01-01")]
g = d.resample("1D").agg(open=("open", "first"), high=("high", "max"),
low=("low", "min"), close=("close", "last")).dropna()
res[a] = {"c2c": g["close"].pct_change().min(),
"o2l": (g["low"] / g["open"] - 1).min(),
"h1": d["close"].pct_change().min()}
print(f" {a}: worst-day {res[a]['c2c']*100:+.1f}% | worst intraday "
f"{res[a]['o2l']*100:+.1f}% | worst 1h-candle {res[a]['h1']*100:+.1f}%")
print(" crolli multi-giorno (close→low): LUNA BTC-29%/ETH-36% · 3AC-giu BTC-44%/ETH-52%"
" · FTX BTC-26%/ETH-32%")
ps = 0.5
scen = {"ETH intraday (-27%)": 0.267, "BTC stretch giu (-44%)": 0.44,
"ETH stretch giu (-52%)": 0.52}
print("\n LEVA DI ROVINA per net-exposure beta (ps=0.5):")
print(f" {'scenario':24}" + "".join(f" b={b}" for b in (0.3, 0.5, 0.7, 1.0)))
for name, dlt in scen.items():
row = f" {name:24}"
for b in (0.3, 0.5, 0.7, 1.0):
L = 1.0 / (b * ps * dlt)
row += f" {L:>4.1f}" + ("!" if L < 10 else " ")
print(row)
print(" ('!' = conto azzerato a leva <10). Le fade fadano = net-long nel crollo "
"(beta alto proprio quando è pericoloso); il disaster-SL -30% NON scatta sotto "
"-30% di mossa singola -> 2022 (max 1h -22%) il book mangia tutto.")
def recommended_leverage() -> None:
"""Leva MAX raccomandata data-driven: quella che tiene il DD di un 2022-repeat sotto
una soglia recuperabile, dato un net-long realistico. Book-shock pesato sulla
composizione reale (4 sleeve BTC + 3 ETH). Vincolo = capitulation peggiore (giugno)."""
from src.data.downloader import load_data
wB, wE, ps = 4 / 7, 3 / 7, 0.5
def stretch(a, s, e):
df = load_data(a, "1h").set_index("datetime")
g = df[(df.index >= s) & (df.index <= e)]
return g["low"].min() / g["open"].iloc[0] - 1
june = wB * stretch("BTC", "2022-06-10", "2022-06-19") + wE * stretch("ETH", "2022-06-10", "2022-06-19")
print("\n=== LEVA MAX RACCOMANDATA (vincolo: sopravvivi a un 2022-repeat) ===")
print(f" book-shock pesato (BTC {wB:.0%}/ETH {wE:.0%}), capitulation giugno = {june*100:.0f}%")
print(f" {'tolleranza DD nel 2022-repeat':30} beta=0.5 beta=0.6 beta=0.7")
for label, dd in [("30% conservativo", .30), ("50% recuperabile", .50),
("70% pre-liquidazione", .70)]:
row = f" {label:30}"
for b in (0.5, 0.6, 0.7):
row += f" {dd/(b*ps*abs(june)):6.1f}x"
print(row)
rec = 0.50 / (0.6 * ps * abs(june))
print(f"\n >>> RACCOMANDATA ~{rec:.1f}x (beta=0.6, DD<=50% recuperabile) -> PRUDENTE {int(rec)}x.")
print(f" A leva 3 (attuale): DD 2022-repeat ~{0.6*ps*3*abs(june)*100:.0f}% (recuperabile). "
f"Leva 5 ~{0.6*ps*5*abs(june)*100:.0f}% (pre-liquidazione). Leva 10 = rovina.")
if __name__ == "__main__":
base, split = _load_base()
print("=== SWEEP LEVA 1→10 su PORT06 (modello daily lineare, == live) ===\n")
sweep(base, split)
per_year(base)
kelly_and_ruin(base, split)
shock_2022()
recommended_leverage()
+80
View File
@@ -0,0 +1,80 @@
"""Smoke end-to-end dell'esecuzione REALE su Deribit testnet.
Dimostra il giro completo che serve al live:
account → OPEN (ordine reale minimo) → VERIFICA posizione su Deribit →
FEE reale dai trade → CLOSE → VERIFICA flat → riepilogo.
Usa il sizing MINIMO del contratto (BTC $10, ETH $1): costo testnet = €0.
NON tocca il runner: e' solo una prova della macchina di esecuzione.
uv run python scripts/analysis/live_exec_smoke.py # ETH + BTC
uv run python scripts/analysis/live_exec_smoke.py ETH-PERPETUAL
"""
from __future__ import annotations
import sys
from src.live.cerbero_client import CerberoClient
from src.live.execution import ExecutionClient, contract_spec, settlement_currency
def smoke_one(ex: ExecutionClient, instrument: str, notional: float = 10.0) -> bool:
spec = contract_spec(instrument)
cur = settlement_currency(instrument)
kind = "lineare USDC" if spec.get("linear") else "inverse"
print(f"\n===== {instrument} ({kind}, ~${notional:.0f} notional) =====")
pre = ex._position_size(instrument)
print(f" posizione pre: {pre} USD (atteso 0)")
print(f" → OPEN buy ${notional:.0f} notional ...")
f = ex.open(instrument, "buy", notional, label="smoke-exec")
print(f" order_id={f.order_id} state={f.order_state} verified={f.verified}")
print(f" fill_price={f.fill_price} amount={f.amount} ({'BTC' if spec.get('linear') else 'USD'})")
print(f" FEE reale: {f.fee_coin:.10f} {cur} (~${f.fee_usd:.4f})")
if f.notes:
print(f" note: {f.notes}")
if not f.verified:
print(" ✗ OPEN NON verificato — interrompo questo strumento")
return False
print(" ✓ posizione confermata su Deribit (riletta da get_positions)")
print(" → CLOSE ...")
c = ex.close(instrument, label="smoke-exec")
print(f" order_id={c.order_id} state={c.order_state} verified(flat)={c.verified}")
print(f" fill_price={c.fill_price} FEE reale: {c.fee_coin:.10f} {cur} (~${c.fee_usd:.4f})")
if c.notes:
print(f" note: {c.notes}")
post = ex._position_size(instrument)
ok = f.verified and c.verified and post == 0
# fee RT reale osservata vs assunto 0.10% RT sul notional
fee_usd_rt = f.fee_usd + c.fee_usd
assumed_rt = notional * 0.001
print(f" posizione post: {post} USD (atteso 0)")
print(f" FEE RT reale ~${fee_usd_rt:.4f} vs assunto 0.10% RT ~${assumed_rt:.4f}")
print(f" {'✓ OK' if ok else '✗ FALLITO'} — giro completo {instrument}")
return ok
def main() -> None:
instruments = sys.argv[1:] or ["ETH-PERPETUAL", "BTC-PERPETUAL"]
client = CerberoClient()
acct = client.get_account_summary(currency="USDC")
print(f"Account testnet: equity={acct.get('equity')} USDC testnet={acct.get('testnet')}")
if not acct.get("testnet"):
print("✗ ABORT: non e' testnet — niente ordini reali su mainnet.")
sys.exit(1)
ex = ExecutionClient(client=client)
results = {inst: smoke_one(ex, inst) for inst in instruments}
print("\n===== RIEPILOGO =====")
for inst, ok in results.items():
print(f" {inst:16s} {'✓ OK' if ok else '✗ FALLITO'}")
sys.exit(0 if all(results.values()) else 1)
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
"""Smoke ESECUZIONE REALE a 2 gambe (PairsExecutionClient) su Deribit testnet.
Apre e chiude una posizione pair micro (ETH/BTC lineari USDC) come farebbe il
PairsWorker, esercitando: open_pair (2 market simultanei long A/short B), verifica
per-gamba, close_pair (2 reduce-only), riconciliazione PnL/fee a 2 gambe.
Verifica finale: conto flat su ENTRAMBI gli strumenti.
Costo testnet = €0. Non tocca lo stato di produzione (data_dir temporanea).
uv run python scripts/analysis/live_pairs_smoke.py
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from src.live.cerbero_client import CerberoClient
from src.live.execution import ExecutionClient, PairsExecutionClient
from src.live.pairs_worker import PairsWorker
def main() -> None:
client = CerberoClient()
acct = client.get_account_summary(currency="USDC")
print(f"Account testnet equity={acct.get('equity')} USDC testnet={acct.get('testnet')}")
if not acct.get("testnet"):
raise SystemExit("ABORT: non testnet")
pex = PairsExecutionClient(leg=ExecutionClient(client=client))
inst = {"ETH": "ETH_USDC-PERPETUAL", "BTC": "BTC_USDC-PERPETUAL"}
pex.ensure_specs(*inst.values())
print(f"strumenti: {inst}")
# GUARDIA: questo conto e' condiviso col runner di produzione (fade reali su
# BTC/ETH_USDC). Se ci sono posizioni aperte, ABORT: lo smoke userebbe lo stesso
# strumento e la verifica "conto flat" sarebbe falsata (e un eventuale
# close_position flatterebbe le quote del runner). Mai eseguire close_position qui.
for i in inst.values():
sz = pex.leg._position_size(i)
if sz != 0:
raise SystemExit(f"ABORT: posizione di produzione aperta su {i} (size={sz}). "
f"Eseguire lo smoke solo a conto flat.")
with tempfile.TemporaryDirectory() as tmp:
# capitale piccolo -> notional micro per gamba (capital*pos*lev)
w = PairsWorker("ETH", "BTC", "1h", capital=60.0, position_size=0.15, leverage=2.0,
data_dir=Path(tmp), executor=pex, exec_instruments=inst)
print(f"execution_enabled={w.execution_enabled} notional/gamba atteso=${60*0.15*2:.0f}")
# prezzi sim plausibili (il ramo sim del worker li usa per il suo ledger;
# il reale usa i fill veri). Niente zeri -> niente divisione per zero.
pa = pex.leg._mark_price(inst["ETH"]) or 1668.0
pb = pex.leg._mark_price(inst["BTC"]) or 63000.0
print("\n[A] OPEN long ratio (long ETH / short BTC)")
w._open(1, pa, pb, -2.5)
print(f" real_in_position={w.real_in_position} dir={w.real_dir}")
print(f" gamba A {w.real_side_a} {w.real_amount_a} @ {w.real_entry_a} (notional ${w.real_notional_a:.2f})")
print(f" gamba B {w.real_side_b} {w.real_amount_b} @ {w.real_entry_b} (notional ${w.real_notional_b:.2f})")
print(f" entry_fee ${w.real_entry_fee:.5f}")
assert w.real_in_position, "open pair non verificato"
assert w.real_side_a == "buy" and w.real_side_b == "sell"
print("\n[B] CLOSE (richiude entrambe le gambe reduce-only)")
cap0 = w.real_capital
pa2 = pex.leg._mark_price(inst["ETH"]) or pa
pb2 = pex.leg._mark_price(inst["BTC"]) or pb
w._close(pa2, pb2, 0.3, "mean_revert")
print(f" real_capital {cap0:.4f} -> {w.real_capital:.4f}{w.real_capital-cap0:+.4f}) trades={w.real_trades}")
assert not w.real_in_position, "close pair non azzera la posizione"
# verifica conto flat su entrambi gli strumenti
pa = pex.leg._position_size(inst["ETH"])
pb = pex.leg._position_size(inst["BTC"])
print(f"\n posizione netta ETH={pa} BTC={pb}")
print("✓ catena pairs OK — open 2 gambe verificate, close 2 gambe, fee reali, conto flat"
if pa == 0 and pb == 0 else
f"⚠ residuo sul book (ETH={pa} BTC={pb}) — verificare manualmente")
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
"""Statistica storica PER ANNO degli sleeve TUTTORA in LIVE REALE (no paper).
Config live = micro-test mainnet PORT06 Fase 1 (portfolios.yml): nel POOL reale
ci sono SOLO i 7 single-leg con esecuzione reale su Deribit mainnet:
- 6 FADE 15m: MR01/MR02/MR07 x BTC/ETH
- DIP01 (BTC 1h)
Tutto il resto (pairs PR01, SH01, TR01/ROT02/TSM01/XS01) e' PAPER -> escluso.
Backtest NETTO fee, leva 3x, pos 15%/sleeve, finestra 2021-2026, OOS = ultimo 30%.
NB: i fade live girano col filtro trend 3.0 (gia' applicato in fade_daily_equity);
i guard live (EXIT-16 confirm, freeze-gate, ecc.) agiscono SOLO sul path live ->
il backtest canonico NON e' filtrato (il live fa MEGLIO sul DD).
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
)
YEARS = sorted(set(IDX.year))
# I 7 sleeve eseguiti reale (vedi portfolios.yml -> execution.sleeves + DIP01).
LIVE_REAL = ["MR01_BTC", "MR01_ETH", "MR02_BTC", "MR02_ETH",
"MR07_BTC", "MR07_ETH", "DIP01_BTC"]
def main():
print("Costruzione equity giornaliere (puo' richiedere ~1-2 min)...\n")
S = build_all_sleeves()
live = {k: S[k] for k in LIVE_REAL} # filtra ai soli 7 reali
# ---------- (A) RET% NETTO per anno, per sleeve ----------
print("=" * 96)
print(" (A) RET% NETTO PER ANNO — sleeve in LIVE REALE (no paper) | leva 3x, fee netta, pos 15%")
print("=" * 96)
print(f" {'sleeve':<14s}" + "".join(f"{y:>9d}" for y in YEARS))
print(" " + "-" * 90)
yr_each = {k: yearly_returns(v.pct_change().fillna(0.0)) for k, v in live.items()}
for k in LIVE_REAL:
print(f" {k.replace('_',' '):<14s}" + "".join(f"{yr_each[k].get(y, 0):>+9.0f}" for y in YEARS))
# pool equal-weight dei 7 (== il pool reale del micro-test)
pool = port_returns(live)
print(" " + "-" * 90)
yr_pool = yearly_returns(pool)
print(f" {'POOL-7 (eq)':<14s}" + "".join(f"{yr_pool.get(y, 0):>+9.0f}" for y in YEARS))
# ---------- (B) metriche FULL / OOS per sleeve + pool ----------
print("\n" + "=" * 96)
print(f" (B) METRICHE — FULL (2021->) | OOS da {OOS_DATE} (ultimo 30%)")
print("=" * 96)
print(f" {'sleeve':<14s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 80)
for k in LIVE_REAL:
dr = live[k].pct_change().fillna(0.0)
f, o = metrics(dr), metrics(dr, lo=SPLIT)
print(f" {k.replace('_',' '):<14s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print(" " + "-" * 80)
f, o = metrics(pool), metrics(pool, lo=SPLIT)
print(f" {'POOL-7 (eq)':<14s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print("\n NB: il backtest canonico NON applica i guard live (EXIT-16 confirm, freeze-gate,")
print(" TP_PHANTOM, ...) -> sul DD il live fa MEGLIO del backtest. Numeri leva 3x.")
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
"""Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi).
Apre e chiude la quota di UN worker fade come farebbe il runner, esercitando i
DUE percorsi del LIMIT reduce-only al TP (fix divergenza sim/reale 2026-06-04):
A) TP lontano → REAL_TP_RESTING in book; exit sim non-TP → cancel + market
reduce-only di fallback (REAL_CLOSE con tp_filled_amount=0);
B) TP gia' oltre il prezzo → il limit crossa e filla SUBITO; la chiusura
riconcilia il fill dal trade history (order_id) SENZA ordine market
(REAL_CLOSE con market_amount=0);
C) SHORT con TP lontano sotto → resting BUY in book + exit non-TP (il path
short non era MAI stato esercitato: tutti i REAL_TP_RESTING storici sono
side=sell — improvement-sweep 2026-06-06);
D) SHORT con TP sopra il prezzo → il buy limit crossa subito → riconciliazione.
In TUTTI gli scenari e' attivo il disaster-bracket (~-30%): verifica che lo
STOP_MARKET reduce-only venga piazzato all'open e cancellato alla chiusura.
Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0.
uv run python scripts/analysis/live_shadow_smoke.py
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from src.live.cerbero_client import CerberoClient
from src.live.execution import ExecutionClient
from src.live.strategy_loader import load_strategy
from src.live.strategy_worker import StrategyWorker
from src.strategies.base import Signal
def main() -> None:
client = CerberoClient()
acct = client.get_account_summary(currency="USDC")
print(f"Account testnet equity={acct.get('equity')} USDC testnet={acct.get('testnet')}")
if not acct.get("testnet"):
raise SystemExit("ABORT: non testnet")
ex = ExecutionClient(client=client)
ex.disaster_sl_pct = 0.30 # come in produzione (portfolios.yml)
instrument = "BTC_USDC-PERPETUAL"
price = ex._mark_price(instrument)
print(f"{instrument} mark={price}")
with tempfile.TemporaryDirectory() as tmp:
w = StrategyWorker(
strategy=load_strategy("MR01_bollinger_fade"),
asset="BTC", tf="1h", capital=100.0, position_size=0.15, leverage=2.0,
data_dir=Path(tmp), executor=ex, exec_instrument=instrument,
)
print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}")
# --- Scenario A: TP lontano → resting in book, exit non-TP → cancel + market ---
print("\n[A] TP lontano (resting) → exit time_limit → cancel + market fallback")
sig = Signal(idx=0, direction=1, entry_price=price,
metadata={"tp": price * 1.05, "sl": price * 0.50, "max_bars": 6})
w._open_position(sig, price)
print(f" real_in_position={w.real_in_position} side={w.real_side} "
f"amount={w.real_amount} entry={w.real_entry_price} "
f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}")
assert w.real_in_position, "OPEN reale non verificato"
print(f" TP resting order_id={w.real_tp_order_id!r} "
f"DSL order_id={w.real_dsl_order_id!r}")
assert w.real_tp_order_id, "LIMIT reduce-only al TP non piazzato"
assert w.real_dsl_order_id, "disaster-SL STOP_MARKET non piazzato"
cap_before = w.real_capital
w._close_position((w.entry_price or price) * 1.001, "time_limit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa"
assert not w.real_tp_order_id, "order_id TP non resettato dopo la chiusura"
assert not w.real_dsl_order_id, "order_id DSL non resettato dopo la chiusura"
# --- Scenario B: TP gia' oltre il prezzo → il limit crossa e filla subito ---
print("\n[B] TP gia' crossato (fill immediato del limit) → close riconcilia da history")
price = ex._mark_price(instrument) or price
sig = Signal(idx=0, direction=1, entry_price=price,
metadata={"tp": price * 0.995, "sl": price * 0.50, "max_bars": 6})
w._open_position(sig, price)
assert w.real_in_position, "OPEN reale non verificato (B)"
assert w.real_tp_order_id, "LIMIT TP non piazzato (B)"
cap_before = w.real_capital
w._close_position(w.tp, "take_profit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione reale non chiusa (B)"
# --- Scenario C: SHORT, TP lontano sotto → resting BUY, exit non-TP ---
print("\n[C] SHORT, TP lontano sotto (resting BUY) → exit time_limit → cancel + market")
price = ex._mark_price(instrument) or price
sig = Signal(idx=0, direction=-1, entry_price=price,
metadata={"tp": price * 0.95, "sl": price * 1.50, "max_bars": 6})
w._open_position(sig, price)
print(f" side={w.real_side} amount={w.real_amount} "
f"TP={w.real_tp_order_id!r} DSL={w.real_dsl_order_id!r}")
assert w.real_in_position and w.real_side == "sell", "OPEN short non verificato (C)"
assert w.real_tp_order_id, "LIMIT BUY reduce-only al TP non piazzato (C)"
assert w.real_dsl_order_id, "disaster-SL short non piazzato (C)"
cap_before = w.real_capital
w._close_position((w.entry_price or price) * 0.999, "time_limit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione short non chiusa (C)"
# --- Scenario D: SHORT, TP sopra il prezzo → il buy limit crossa subito ---
print("\n[D] SHORT, TP gia' crossato (buy limit marketable) → riconciliazione da history")
price = ex._mark_price(instrument) or price
sig = Signal(idx=0, direction=-1, entry_price=price,
metadata={"tp": price * 1.005, "sl": price * 1.50, "max_bars": 6})
w._open_position(sig, price)
assert w.real_in_position and w.real_side == "sell", "OPEN short non verificato (D)"
cap_before = w.real_capital
w._close_position(w.tp, "take_profit")
print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} "
f"{w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}")
assert not w.real_in_position, "posizione short non chiusa (D)"
# verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker)
pos = ex._position_size(instrument)
print(f"\n posizione netta {instrument}: {pos}")
print("✓ catena shadow OK — open reale long+short, LIMIT TP resting due lati "
"(cancel+market e fill immediato riconciliato), disaster-SL on-book "
"piazzato/cancellato, fee reali nel ledger reale")
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Smoke test REALE dei pairs: fetch live da Cerbero + un tick vero per coppia.
A differenza di validate_worker_pairs.py (replay su parquet storici), questo verifica
la PIPELINE LIVE end-to-end: chiama Cerbero per entrambe le gambe, controlla che lo
strumento esista e sia fresco, fa un tick reale del PairsWorker e riporta lo stato.
Serve a scoprire i problemi che il backtest nasconde (es. un perp alt non disponibile
sull'endpoint Deribit). NON apre ordini reali: e' solo paper/lettura.
"""
from __future__ import annotations
import sys
import shutil
import tempfile
from datetime import datetime, timezone, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
from src.live.pairs_worker import PairsWorker
from scripts.strategies.PR01_pairs_reversion import PAIRS
def fetch(cli, asset, start, end):
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
try:
cs = cli.get_historical(inst, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), "60")
if not cs:
return inst, None, "VUOTO (strumento assente sull'endpoint)"
df = pd.DataFrame(cs)
df["timestamp"] = df["timestamp"].astype("int64")
df = df.sort_values("timestamp").reset_index(drop=True)
last = pd.to_datetime(df["timestamp"].iloc[-1], unit="ms", utc=True)
age = (datetime.now(timezone.utc) - last).total_seconds() / 3600
return inst, df, f"{len(df)} barre, ultima {last:%Y-%m-%d %H:%M} ({age:.1f}h fa)"
except Exception as e:
return inst, None, f"ERRORE {type(e).__name__}: {str(e)[:60]}"
def main():
cli = CerberoClient()
end = datetime.now(timezone.utc)
start = end - timedelta(days=60)
assets = sorted({a for a, _, _ in PAIRS} | {b for _, b, _ in PAIRS})
print("=" * 80)
print(" SMOKE TEST LIVE PAIRS — fetch reale Cerbero + tick (no ordini reali)")
print("=" * 80)
data = {}
for a in assets:
inst, df, msg = fetch(cli, a, start, end)
data[a] = df
print(f" {a:<4s} [{inst:<16s}] {msg}")
print("\n tick reale per coppia:")
tmp = Path(tempfile.mkdtemp())
try:
for a, b, p in PAIRS:
if data.get(a) is None or data.get(b) is None:
print(f" {a}/{b:<4s}: SKIP (manca feed live di una gamba) -> non tradabile live ora")
continue
w = PairsWorker(a, b, "1h", params=p, fee_rt=0.001, data_dir=tmp)
w._log = lambda *x, **k: None
w._notify = lambda *x, **k: None
m = data[a][["timestamp", "close"]].merge(
data[b][["timestamp", "close"]], on="timestamp", how="inner")
if len(m) < p["n"] + 2:
print(f" {a}/{b:<4s}: merge {len(m)} barre < n+2 ({p['n']+2}) -> dati insufficienti")
continue
z, _ = w._zscore(m["close_x"].values, m["close_y"].values)
w.tick(data[a], data[b])
state = ("IN POS " + ("LONG " + a if w.direction == 1 else "SHORT " + a)
if w.in_position else "FLAT")
print(f" {a}/{b:<4s}: OK merge {len(m)} barre, z_ora={z[-1]:+.2f} -> {state}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
print("\n Solo le coppie con entrambe le gambe fresche su Cerbero sono tradabili live.")
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
"""VERIFICA DEFINITIVA — la fade ha edge sul perp Deribit MAINNET REALE?
Testnet feed = print fantasma (edge finto). Binance spot = fade morta. Resta da
escludere che la fade viva sulla microstruttura del perp Deribit MAINNET reale (dove
ESEGUE davvero). Scarico lo storico 15m reale (BTC/ETH-PERPETUAL mainnet via Cerbero v2,
token .env.mainnet) e giro lo STESSO engine. Cache in data/raw/_mainnet_*.parquet
(NON tocca i file canonici).
"""
from __future__ import annotations
import sys
from pathlib import Path
from datetime import datetime, timedelta
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import requests, numpy as np, pandas as pd
from src.live.cerbero_client import TESTNET_TOKEN
from scripts.analysis.risk_management import build_trades, strats_for
URL = "https://cerbero-mcp.tielogic.xyz/mcp/tools/get_historical"
START, END = "2020-06-01", "2026-05-26"
YEARS = [2021, 2022, 2023, 2024, 2025, 2026]
INSTR = {"BTC": "BTC-PERPETUAL", "ETH": "ETH-PERPETUAL"}
def _token():
env = {}
for ln in (PROJECT_ROOT / ".env.mainnet").read_text().splitlines():
ln = ln.strip()
if ln and not ln.startswith("#") and "=" in ln:
k, v = ln.split("=", 1); env[k] = v.strip()
assert env["CERBERO_TOKEN"] != TESTNET_TOKEN, "token e' TESTNET, non mainnet!"
return env["CERBERO_TOKEN"], env.get("CERBERO_BOT_TAG", "pythagoras-verify")
def fetch_mainnet(asset, tf="15m"):
cache = PROJECT_ROOT / "data" / "raw" / f"_mainnet_{asset.lower()}_{tf}.parquet"
if cache.exists():
return pd.read_parquet(cache)
tok, tag = _token()
H = {"Authorization": f"Bearer {tok}", "X-Bot-Tag": tag, "Content-Type": "application/json"}
cur, end = datetime.fromisoformat(START), datetime.fromisoformat(END)
step = timedelta(days=30)
rows = []
while cur < end:
ce = min(cur + step, end)
for attempt in range(3):
try:
r = requests.post(URL, headers=H, json={
"exchange": "deribit", "instrument": INSTR[asset], "interval": tf,
"start_date": cur.strftime("%Y-%m-%d"), "end_date": ce.strftime("%Y-%m-%d")},
timeout=40)
r.raise_for_status()
rows += r.json().get("candles", [])
break
except Exception as e:
if attempt == 2:
print(f" SKIP {cur.date()}->{ce.date()}: {str(e)[:60]}")
cur = ce
df = pd.DataFrame(rows)
if len(df) == 0:
return df
df["timestamp"] = df["timestamp"].astype("int64")
df = df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
df.to_parquet(cache, index=False)
return df
def yearly(ts, trades, pos=0.15):
by = {y: [] for y in YEARS}
for i, j, r in trades:
y = ts.iloc[i].year
if y in by: by[y].append(r)
out = {}
for y in YEARS:
cap = 1000.0
for r in by[y]: cap = max(cap + cap * pos * r, 10.0)
out[y] = (cap / 1000 - 1) * 100
return out
def full_oos(ts, trades, pos=0.15, split_date="2024-10-12"):
sd = pd.Timestamp(split_date, tz="UTC")
def comp(sub):
cap = 1000.0; rets = []
for i, j, r in sub:
cap = max(cap + cap * pos * r, 10.0); rets.append(r * pos)
return cap, rets
capF, rF = comp(trades)
capO, rO = comp([(i, j, r) for i, j, r in trades if ts.iloc[i] >= sd])
shF = float(np.mean(rF)/np.std(rF)*np.sqrt(len(rF))) if len(rF) > 1 and np.std(rF) > 0 else 0.0
shO = float(np.mean(rO)/np.std(rO)*np.sqrt(len(rO))) if len(rO) > 1 and np.std(rO) > 0 else 0.0
return (capF/1000-1)*100, shF, (capO/1000-1)*100, shO
def main():
print(f"Fetch Deribit MAINNET 15m REALE ({START}->{END})...\n")
data = {}
for a in ("BTC", "ETH"):
df = fetch_mainnet(a)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
print(f" {INSTR[a]}: {len(df)} candele {ts.min()} -> {ts.max()} "
f"(anni: {sorted(ts.dt.year.unique().tolist())})")
data[a] = df
print("\n" + "=" * 94)
print(" FADE su perp Deribit MAINNET REALE 15m | RET% per anno (pos 0.15, leva 3x, trend 3.0)")
print("=" * 94)
print(f" {'sleeve':<12s}" + "".join(f"{y:>9d}" for y in YEARS) + " | FULL% Shrp | OOS% Shrp")
print(" " + "-" * 90)
for asset in ("BTC", "ETH"):
df = data[asset]
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
for code in ("MR01", "MR02", "MR07"):
fn, params = strats_for(asset)[code]
trades = build_trades(fn(df, **params), df, trend_max=3.0)
trades = [(i, j, r) for i, j, r in trades if ts.iloc[i].year >= 2021]
yr = yearly(ts, trades)
fF, shF, fO, shO = full_oos(ts, trades)
print(f" {code+'_'+asset:<12s}" + "".join(f"{yr[y]:>+9.0f}" for y in YEARS) +
f" | {fF:>+8.0f} {shF:>5.2f} | {fO:>+6.0f} {shO:>5.2f}")
if __name__ == "__main__":
main()
+851
View File
@@ -0,0 +1,851 @@
"""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&lt;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&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, 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()
@@ -0,0 +1,129 @@
export const meta = {
name: 'mr02eth-options-protection',
description: 'Overlay di opzioni (protezione coda) sul fade ETH: 18 design DVOL-prezzati + sintesi con audit crash reali',
phases: [
{ title: 'Options', detail: '18 agenti, overlay opzione distinto ciascuno, prezzato via DVOL+BS, premio dedotto' },
{ title: 'Synthesis', detail: 'audit crash reali (FTX/maggio-21/ago-24) e sintesi del miglior overlay deployabile' },
],
}
const RECIPE = `Lavora in /opt/docker/PythagorasGoal. Testa UN overlay di OPZIONI come protezione di coda sul fade ETH (per rendere DEPLOYABILE la versione ad alto Sharpe e cappare i crash).
Usa l'harness condiviso scripts.analysis.option_overlay_lab (gia' pronto e verificato):
from scripts.analysis.option_overlay_lab import get_df, dvol_for, donchian_fade, simulate_hedged, evaluate_hedged, bs_put, bs_call
- df = get_df("ETH","1h"); dv = dvol_for(df,"ETH") # DVOL frazione, causale (oraria 2021-03 -> 2026-06; pre-2021 e' bfilled approssimato)
- donchian_fade(df, n=20, sl_atr=2.0, max_bars=24, trend_max=3.0, ema_long=200, use_sl=True/False) -> entries MR02/ETH (use_sl=False = togli lo stop ATR)
- simulate_hedged(entries, df, dv, hedge=..., otm=..., otm2=..., skew_mult=1.10, tenor_mult=1.0, hedge_side="both|long|short", min_dvol=0.0, split=-1) -> dict(trades,win,ret,dd,sharpe,yearly,exposure,prem_paid_pct,pay_recv_pct)
- evaluate_hedged(name, entries, df, dv, **hcfg) -> {full,oos,sweep,sweep_oos,pos_yrs,n_yrs} e stampa una riga (prem/pay = premi pagati e payoff incassati cumulati %).
hedge: "none" | "put" (put se long / call se short, floor a otm) | "put_spread" (compra otm, vende otm2 piu' lontano) | "collar" (compra protezione otm, vende l'opzione opposta a otm2 per finanziare).
Prezzo opzioni: Black-Scholes(spot, DVOL) r=0, DELIBERATAMENTE CONSERVATIVO (bias CONTRO le opzioni): skew_mult rincara la sigma dei comprati, all'uscita anticipata l'opzione vale solo l'INTRINSECO. Se sotto queste ipotesi pessimistiche l'overlay aiuta, e' robusto. I numeri sono INDICATIVI (la DVOL e' reale e gratuita, ma niente fill/skew per-strike reali).
RIFERIMENTO gia' misurato (ETH 1h, fee 0.10% RT, lev 3x, pos 0.15):
- baseline MR02/ETH con SL-ATR + trend3.0: Sharpe 8.45, full DD 25%, OOS DD 13%.
- no-SL (stesso fade SENZA stop ATR): Sharpe 11.63, full DD 31%, OOS DD 7%, anniPos 8/9 (togliere lo stop e' il salto piu' grande, coerente con EXIT-16; ma sl=0 NON e' deployabile per il rischio coda da crash -> qui le opzioni servono a cappare quella coda).
- no-SL + put 8% OTM: Sharpe 12.13, full DD 33%, premio cum 360% ~ break-even col payoff 255%.
- no-SL + put 3% OTM: premio mostruoso (2363%), peggiora -> troppo vicina.
OBIETTIVO: trovare l'overlay che dia il MIGLIOR rischio/rendimento DEPLOYABILE. Conta soprattutto: (a) Sharpe FULL e OOS >= no-SL, (b) drawdown contenuto, (c) la CODA nei crash veri sia cappata (l'opzione DEVE pagare quando ETH crolla), (d) premio netto sostenibile, (e) regge skew_mult piu' alto. Focalizza la lettura sul 2021+ e sull'OOS (DVOL reale).
Procedura: implementa/configura il tuo overlay (puoi scrivere uno script in /tmp/opt_KEY.py se serve, ma puoi anche chiamare direttamente le funzioni), fai uno sweep PICCOLO dei suoi parametri, scegli la config migliore. Riporta i numeri VERI dall'esecuzione. Sii onesto: se l'overlay non aiuta, dillo (e' informazione utile).`
const SCHEMA = {
type: 'object', additionalProperties: false,
required: ['key','structure','base','ran_ok','best_config','full_sharpe','full_ret','full_dd','oos_sharpe','oos_dd','prem_pct','pay_pct','full_dd_skew150','deployable','improves_over_noSL','improves_over_baseline','no_lookahead','tail_note','note'],
properties: {
key: { type: 'string' },
structure: { type: 'string', description: 'put | put_spread | collar | standing | other' },
base: { type: 'string', description: 'no-SL | SL-ATR | exit16' },
ran_ok: { type: 'boolean' },
best_config: { type: 'string' },
full_sharpe: { type: 'number' },
full_ret: { type: 'number' },
full_dd: { type: 'number' },
oos_sharpe: { type: 'number' },
oos_dd: { type: 'number' },
prem_pct: { type: 'number', description: 'premio cumulato pagato % (full)' },
pay_pct: { type: 'number', description: 'payoff cumulato incassato % (full)' },
full_dd_skew150: { type: 'number', description: 'full DD con skew_mult=1.5 (stress del premio); -1 se non testato' },
deployable: { type: 'boolean', description: 'coda cappata + premio sostenibile -> deployabile (a differenza del no-SL nudo)' },
improves_over_noSL: { type: 'boolean' },
improves_over_baseline: { type: 'boolean', description: 'batte MR02/ETH baseline SL-ATR (Sharpe 8.45 / DD 25)' },
no_lookahead: { type: 'boolean', description: 'DVOL causale, ingresso a close[i], nessun look-ahead' },
tail_note: { type: 'string', description: 'comportamento nei crash: l opzione paga? di quanto cappa la perdita peggiore?' },
note: { type: 'string' },
},
}
const SPECS = [
['put_floor_sweep','put','no-SL','no-SL + put protettiva (call sugli short). Sweep OTM 5/8/10/12%. Trova il floor che massimizza Sharpe a premio netto sostenibile. Riferimento: 8% sembra il punto dolce.'],
['put_spread_ladder','put_spread','no-SL','no-SL + debit put-spread (compra otm, vende otm2 piu lontano) per abbattere il premio. Sweep (otm,otm2): (5,15),(8,20),(8,25),(10,25). Il cap del payoff e accettabile se i crash ETH raramente vanno oltre otm2 nell orizzonte.'],
['collar_financed','collar','no-SL','no-SL + collar (vende call OTM per finanziare la put). Sweep (put_otm, call_otm): (5,8),(8,10),(8,15),(10,15). Attenzione: la call venduta cappa gli short-fade vincenti -> testa se il netto regge.'],
['atm_put_shortdated','put','no-SL','no-SL + put quasi-ATM ma tenor minimo (tenor_mult=1, copre solo l orizzonte): e mai conveniente la protezione massima a scadenza cortissima? Sweep otm 0/2/3%. Probabile premio troppo alto: verifica.'],
['longer_tenor_put','put','no-SL','no-SL + put a tenor PIU LUNGO (tenor_mult 3/5/7 = settimanale/mensile): il time-value per-ora e piu basso, e la stessa put copre piu trade. Sweep tenor_mult e otm. Confronta premio cum vs tenor=1.'],
['regime_gated_put_high','put','no-SL','no-SL + put SOLO quando DVOL all ingresso > soglia (min_dvol 0.8/1.0/1.2): proteggi solo nei regimi di vol elevata (dove i crash si addensano), resta nudo nella calma. Sweep soglia/otm. Riduce il premio totale?'],
['regime_gated_put_low','put','no-SL','no-SL + put SOLO quando DVOL e BASSA (assicurazione a buon mercato prima che la vol esploda). Implementa un gate min_dvol-inverso (modifica/wrap: salta l hedge se dvol>soglia). Test controintuitivo: la calma e dove la coda e meno prezzata.'],
['long_only_floor','put','no-SL','no-SL + put SOLO sui long-fade (hedge_side="long", le prese-coltello su un ETH che crolla = il failure mode documentato di MR02). Gli short-fade restano nudi. Sweep otm. Dimezza il premio focalizzandolo dove serve?'],
['short_only_cap','put','no-SL','no-SL + call SOLO sugli short-fade (hedge_side="short"): cappa il rischio di shortare un rimbalzo esplosivo. Sweep otm. ETH rimbalza meno violentemente di quanto crolla -> probabilmente meno utile dei long: misura.'],
['asym_put_call','put','no-SL','no-SL + protezione ASIMMETRICA: put piu vicina sui long (ETH crolla forte e veloce), call piu lontana sugli short. Implementa due chiamate simulate_hedged side-split, oppure due passate con hedge_side e somma le equity. Sweep (put_otm, call_otm).'],
['skew_stress','put','no-SL','Prendi la migliore put-floor (es 8% OTM) e fai lo STRESS dello skew: skew_mult 1.0/1.1/1.25/1.5/1.75. A che skew il premio mangia l edge? Riporta la sensibilita: e l overlay sopravvive allo skew realistico (~1.2-1.4 sui put cripto)?'],
['dynamic_otm_dvol','put','no-SL','OTM dinamico in funzione di DVOL: protezione piu lontana quando la vol e alta (la stessa % di mossa e piu probabile -> protezione relativamente piu economica). Implementa un loop che varia otm = base + k*(dvol-mediana). Sweep base/k.'],
['exit16_base_put','put','exit16','Base = EXIT-16 (close-confirm SL, gia LIVE) invece del no-SL nudo: applica la put-floor sopra l EXIT-16. Modella EXIT-16 come SL-at-level approssimato (usa donchian_fade con use_sl=True ma sl piu largo, sl_atr 3.0) + put. Confronta: l opzione aggiunge valore SOPRA l EXIT-16 gia deployato?'],
['standing_weekly_put','standing','no-SL','Put SETTIMANALE STANDING su notional ETH fisso (non per-trade): compra una put 1-settimana ~10% OTM ogni 168h, rolla, ammortizza il premio su TUTTI i trade della settimana. Implementa una serie di payoff/premio settimanale dalla DVOL e sommala all equity del fade no-SL. Confronta col per-trade.'],
['portfolio_tail_overlay','standing','no-SL','Overlay di coda a livello SLEEVE: una put mensile ~15-20% OTM su notional ETH costante come assicurazione catastrofica (rara ma paga nei crash -30%+). Misura il costo annuo (premio) vs la riduzione del DD/coda dello sleeve no-SL. E un trade-off accettabile (pochi bps/anno per cappare il -39% FTX)?'],
['funding_financed','put','no-SL','Finanzia il premio della put col CARRY del funding perp: quando il funding e positivo, il long perp incassa funding che offre il premio. Usa eth_funding (interest_8h) per stimare il carry accumulato per-trade e sottrailo dal premio netto. Il funding copre quanto del premio?'],
['ratio_put_spread','put_spread','no-SL','Ratio put-spread (compra 1 put otm, vende 2 put otm2): premio quasi-zero o credito, ma reintroduce rischio sotto otm2. Approssima con put_spread + una seconda put venduta. Test del trade-off: il credito vale il rischio di coda residuo? Audita i crash.'],
['best_combo','put','no-SL','SINTESI best-guess: no-SL + put-floor regime-gated (DVOL>0.8) all OTM ottimale + funding-offset. Combina le leve migliori che ti aspetti e riporta full/OOS Sharpe, DD, premio netto, e il comportamento nei 3 peggiori crash. E questo il candidato deployabile?'],
]
phase('Options')
log(`Lancio ${SPECS.length} agenti di overlay-opzione (DVOL-prezzati, premio conservativo)`)
const results = await parallel(SPECS.map(([key, structure, base, idea]) => () =>
agent(
`${RECIPE}\n\n========\nIL TUO OVERLAY — key="${key}", structure="${structure}", base="${base}":\n${idea}\n\nRestituisci il risultato strutturato (key/structure/base coi tuoi valori).`,
{ label: `opt:${key}`, phase: 'Options', schema: SCHEMA }
).then(r => r ? { ...r, key, structure, base } : null)
))
const ran = results.filter(Boolean)
log(`${ran.length}/${SPECS.length} overlay rientrati`)
// candidati deployabili interessanti: girati, no look-ahead, migliorano almeno la baseline, coda cappata
const good = ran.filter(r => r.ran_ok && r.no_lookahead && r.deployable &&
(r.improves_over_baseline || r.improves_over_noSL))
good.sort((a, b) => (b.oos_sharpe - 0.1 * b.full_dd) - (a.oos_sharpe - 0.1 * a.full_dd))
const top = good.slice(0, 6)
phase('Synthesis')
log(`Sintesi: audit crash reali sui top ${top.length} overlay`)
const SYN = {
type: 'object', additionalProperties: false,
required: ['recommendation','deployable_config','rationale','crash_audit','caveats','full_sharpe','full_dd','oos_sharpe','oos_dd','net_premium_drag_pct'],
properties: {
recommendation: { type: 'string', description: 'verdetto: usare le opzioni si/no e come' },
deployable_config: { type: 'string', description: 'la config esatta consigliata (struttura, OTM, tenor, gating)' },
rationale: { type: 'string' },
crash_audit: { type: 'string', description: 'comportamento nei crash reali (FTX nov-22, crash mag-21, ago-24): perdita no-SL vs hedged' },
caveats: { type: 'string', description: 'limiti del modello DVOL-BS (no skew/fill reali), rischio esecuzione' },
full_sharpe: { type: 'number' },
full_dd: { type: 'number' },
oos_sharpe: { type: 'number' },
oos_dd: { type: 'number' },
net_premium_drag_pct: { type: 'number', description: 'premio netto (premio-payoff) cumulato % della config consigliata' },
},
}
const summary = await agent(
`${RECIPE}\n\n========\nSEI L'AGENTE DI SINTESI. Hai i risultati dei migliori overlay-opzione sul fade ETH:\n${JSON.stringify(top, null, 1)}\n\nFai un AUDIT dei CRASH REALI: ricostruisci (con option_overlay_lab) il comportamento della migliore config nelle finestre di crollo ETH piu' severe (crash maggio 2021, collasso FTX nov 2022, e altri drawdown -25%+ del 2024/2025). Per ciascuna mostra: la perdita peggiore del fade no-SL NUDO vs con l'overlay (l'opzione DEVE pagare li). Scrivi uno script /tmp/crash_audit.py se serve (filtra i trade per finestra temporale e confronta).\n\nPoi dai la RACCOMANDAZIONE finale: conviene usare le opzioni per proteggere lo sleeve ETH? Quale config esatta e' deployabile (struttura, OTM, tenor, regime-gating)? Con quali numeri (full/OOS Sharpe, DD, premio netto)? E i CAVEAT onesti (il modello DVOL-BS non ha skew/fill reali; serve verificare liquidita'/prezzi reali su Deribit prima di rischiare capitale). Sii sobrio e onesto.`,
{ label: 'synthesis', phase: 'Synthesis', schema: SYN }
)
return {
n_specs: SPECS.length, n_ran: ran.length, n_good: good.length,
recommendation: summary,
top_overlays: top.map(r => ({ key: r.key, structure: r.structure, base: r.base, best_config: r.best_config,
full_sharpe: r.full_sharpe, full_dd: r.full_dd, oos_sharpe: r.oos_sharpe, oos_dd: r.oos_dd,
prem_pct: r.prem_pct, pay_pct: r.pay_pct, deployable: r.deployable, tail_note: r.tail_note, note: r.note })),
all: ran.map(r => ({ key: r.key, structure: r.structure, full_sharpe: r.full_sharpe, full_dd: r.full_dd,
oos_sharpe: r.oos_sharpe, oos_dd: r.oos_dd, prem_pct: r.prem_pct, pay_pct: r.pay_pct,
deployable: r.deployable, improves_over_baseline: r.improves_over_baseline, improves_over_noSL: r.improves_over_noSL, note: r.note })),
}
+338
View File
@@ -0,0 +1,338 @@
"""GATE PORT06: i top candidati sostituiscono MR02/ETH. Misura FULL+OOS Sharpe/DD.
Ogni candidato genera i trade ETH 1h con l'ENGINE INTRABAR identico al sleeve canonico
(explore_lab.simulate: SL/TP intrabar al livello, fee 0.10% RT, lev 3x), equity giornaliera
normalizzata su IDX (2021-01-01 -> 2026-05-26), swap su all_sleeve_equities()['MR02_ETH'],
e ri-misura PORT06 (cap weighting PAIRS 0.33 / SHAPE 0.0588).
uv run python scripts/analysis/mr02eth_port06_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df, atr, ema
from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns, _norm
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
CAPS = {"PAIRS": 0.33, "SHAPE": 0.0588}
# ----------------------- engine intrabar (== explore_lab.simulate) -----------------------
def build_trades(entries, df, fee_rt=FEE_RT, lev=LEV):
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c); out = []; last = -1
for e in entries:
i, d = e["i"], e["d"]
if i <= last or i + 1 >= n:
continue
entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
out.append((i, j, (exit_p - entry) / entry * d * lev - fee_rt * lev)); last = j
return out
def build_trades_exit16(entries, df, sl_confirm=0.5, fee_rt=FEE_RT, lev=LEV,
dvol=None, otm=None, skew=1.10, tenor_mult=1.0):
"""Engine EXIT-16 close-confirm (== config LIVE): SL intrabar OFF, lo stop scatta solo se il
CLOSE sfonda sl ∓ sl_confirm*ATR14; TP intrabar ha priorita'.
Se dvol+otm sono dati, AGGIUNGE un overlay opzione (put se long / call se short) a otm OTM."""
from scripts.analysis.option_overlay_lab import bs_put, bs_call
h, l, c = df["high"].values, df["low"].values, df["close"].values
a = atr(df, 14); n = len(c); out = []; last = -1
for e in entries:
i, d = e["i"], e["d"]
if i <= last or i + 1 >= n:
continue
entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_tp:
exit_p = tp; break
if sl is not None and not np.isnan(a[j]):
buf = sl_confirm * a[j]
if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf):
exit_p = c[j]; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee_rt * lev
if dvol is not None and otm is not None:
T = max(mb * tenor_mult, 1.0) / _HOURS_YEAR; sig = dvol[i] * skew
if d == 1:
K = entry * (1 - otm); prem = bs_put(entry, K, T, sig) / entry; pay = max(K - exit_p, 0.0) / entry
else:
K = entry * (1 + otm); prem = bs_call(entry, K, T, sig) / entry; pay = max(exit_p - K, 0.0) / entry
ret += lev * (pay - prem)
out.append((i, j, ret)); last = j
return out
def blend_equity(eqs, weights=None) -> pd.Series:
"""Combina N equity giornaliere mediando i rendimenti giornalieri (ribilancio daily)."""
drs = [e.pct_change().fillna(0.0) for e in eqs]
w = weights or [1.0 / len(drs)] * len(drs)
dr = sum(wi * di for wi, di in zip(w, drs))
return _norm((1 + dr).cumprod())
def daily_equity(trades, df) -> pd.Series:
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
n = len(df); eq = np.full(n, 1000.0); cap = 1000.0
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * POS * ret, 10.0); eq[j:] = cap
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
# ----------------------- indicatori -----------------------
def choppiness(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)))
str_ = pd.Series(tr).rolling(n).sum().values
hh = pd.Series(h).rolling(n).max().values
ll = pd.Series(l).rolling(n).min().values
rng = hh - ll
with np.errstate(divide="ignore", invalid="ignore"):
ci = 100.0 * np.log10(str_ / rng) / np.log10(n)
return ci
def var_ratio(close, k=30, win=100):
r1 = pd.Series(close).pct_change()
rk = pd.Series(close).pct_change(k)
v1 = r1.rolling(win).var().values
vk = rk.rolling(win).var().values
with np.errstate(divide="ignore", invalid="ignore"):
vr = vk / (k * v1)
return vr
def donchian_levels(df, n):
hh = pd.Series(df["high"].values).rolling(n).max().shift(1).values
ll = pd.Series(df["low"].values).rolling(n).min().shift(1).values
return hh, ll
# ----------------------- generatori di segnale (candidati) -----------------------
def gen_donchian_base(df, n=20, sl_atr=2.0, max_bars=24, trend_max=None, ema_long=200, gate=None, use_sl=True):
"""gate(i)->bool: True = consenti il segnale alla barra i. None = sempre. use_sl=False -> sl=None (no-SL)."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
a = atr(df, 14); hh, ll = donchian_levels(df, n); em = ema(c, ema_long)
ents = []
for i in range(max(n, ema_long, 14) + 1, len(c)):
if np.isnan(hh[i]) or np.isnan(ll[i]) or np.isnan(a[i]) or a[i] <= 0:
continue
if trend_max is not None and not np.isnan(em[i]) and abs(c[i] - em[i]) / a[i] > trend_max:
continue
if gate is not None and not gate(i):
continue
tp = (hh[i] + ll[i]) / 2.0
if c[i] < ll[i] and c[i - 1] >= ll[i - 1]:
ents.append({"i": i, "d": 1, "tp": tp, "sl": (c[i] - sl_atr * a[i]) if use_sl else None, "max_bars": max_bars})
elif c[i] > hh[i] and c[i - 1] <= hh[i - 1]:
ents.append({"i": i, "d": -1, "tp": tp, "sl": (c[i] + sl_atr * a[i]) if use_sl else None, "max_bars": max_bars})
return ents
# ----------------------- engine intrabar + overlay opzione (per i candidati no-SL) -----------------------
_HOURS_YEAR = 24 * 365.0
def build_trades_hedged(entries, df, dvol, otm=0.10, skew=1.10, tenor_mult=1.0, fee_rt=FEE_RT, lev=LEV):
from scripts.analysis.option_overlay_lab import bs_put, bs_call
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c); out = []; last = -1
for e in entries:
i, d = e["i"], e["d"]
if i <= last or i + 1 >= n:
continue
entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
base = (exit_p - entry) / entry * d * lev - fee_rt * lev
T = max(mb * tenor_mult, 1.0) / _HOURS_YEAR; sig = dvol[i]; sigb = sig * skew
if d == 1:
K = entry * (1.0 - otm); prem = bs_put(entry, K, T, sigb) / entry; pay = max(K - exit_p, 0.0) / entry
else:
K = entry * (1.0 + otm); prem = bs_call(entry, K, T, sigb) / entry; pay = max(exit_p - K, 0.0) / entry
out.append((i, j, base + lev * (pay - prem))); last = j
return out
def cand_choppiness_gate_fade(df):
ci = choppiness(df, 14)
return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=None,
gate=lambda i: not np.isnan(ci[i]) and ci[i] >= 50.0)
def cand_choppiness_donchian(df):
ci = choppiness(df, 14)
return gen_donchian_base(df, n=14, sl_atr=2.0, trend_max=3.0,
gate=lambda i: not np.isnan(ci[i]) and ci[i] > 61.8)
def cand_varratio_gate_fade(df):
vr = var_ratio(df["close"].values, k=30, win=100)
return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0,
gate=lambda i: not np.isnan(vr[i]) and vr[i] < 0.95)
def cand_baseline_recon(df):
"""MR02/ETH canonico ricostruito col MIO engine (sanity check vs all_sleeve_equities)."""
return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0)
def cand_vrp_neg_dvol_low(df):
from scripts.analysis.regime_lab import load, regime_features
rdf = load("ETH", "1h")
R = regime_features(rdf)
# allinea per indice posizionale (regime_lab.load parte da get_df, stesso ordinamento)
vrp = R["vrp"]; dvp = R["dvol_pct"]
m = min(len(df), len(vrp))
def gate(i):
if i >= m:
return False
return (not np.isnan(vrp[i]) and vrp[i] < 0) and (not np.isnan(dvp[i]) and dvp[i] < 0.60)
return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=None, gate=gate)
# ----------------------- gate PORT06 -----------------------
def port_metrics(members, ids):
w = W.weight_vector("cap", ids, None, caps=CAPS)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def main():
print("=" * 104)
print(f" GATE PORT06 — sostituire MR02/ETH | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da {OOS_DATE}")
print("=" * 104)
eq = dict(all_sleeve_equities())
ids = [k for k in eq if k in {
"MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH",
"DIP01_BTC","TR01_basket","ROT02_rot",
"PR_ETHBTC","PR_LTCETH","PR_ADAETH","PR_BTCLTC","PR_ETHSOL","TSM01","SH_BTC","SH_ETH"}]
print(f" sleeve PORT06: {len(ids)} | MR02_ETH presente: {'MR02_ETH' in ids}")
f_b, o_b = port_metrics(eq, ids)
print(f"\n {'variante':<22s}{'FULL Sh':>8s}{'FULL DD':>8s}{'FULL CAGR':>10s} |{'OOS Sh':>8s}{'OOS DD':>8s}{'OOS CAGR':>9s}")
print(" " + "-" * 100)
print(f" {'BASELINE (canonico)':<22s}{f_b['sharpe']:>8.2f}{f_b['dd']:>8.2f}{f_b['cagr']:>9.0f}% |{o_b['sharpe']:>8.2f}{o_b['dd']:>8.2f}{o_b['cagr']:>8.0f}%")
df = get_df("ETH", "1h")
from scripts.analysis.option_overlay_lab import dvol_for
dvol = dvol_for(df, "ETH")
# candidati: (nome, builder) dove builder(df)->trades
def b_signal(fn):
return lambda df: build_trades(fn(df), df)
base_ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0)
def b_exit16(df):
return build_trades_exit16(base_ents, df, sl_confirm=0.5)
def b_exit16_put10(df):
return build_trades_exit16(base_ents, df, sl_confirm=0.5, dvol=dvol, otm=0.10)
def b_noSL(df):
return build_trades(gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False), df)
def b_noSL_put10(df):
ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False)
return build_trades_hedged(ents, df, dvol, otm=0.10)
cands = {
"MR02recon(sanity)": b_signal(cand_baseline_recon),
"varratio_gate": b_signal(cand_varratio_gate_fade),
"choppiness_donch": b_signal(cand_choppiness_donchian),
"vrp_neg_dvol_low": b_signal(cand_vrp_neg_dvol_low),
"EXIT16(live)": b_exit16,
"EXIT16+put10%OTM": b_exit16_put10,
"noSL_raw": b_noSL,
"noSL_put10%OTM": b_noSL_put10,
}
rows = []
equ = {} # salva le equity per i blend
for name, fn in cands.items():
try:
tr = fn(df)
ce = daily_equity(tr, df)
equ[name] = ce
m2 = dict(eq); m2["MR02_ETH"] = ce
f_c, o_c = port_metrics(m2, ids)
rows.append((name, len(tr), f_c, o_c))
print(f" {name:<22s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f}{f_c['cagr']:>9.0f}% |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}{o_c['cagr']:>8.0f}%"
f" ({len(tr)} trade)")
except Exception as ex:
print(f" {name:<22s} ERRORE: {ex}")
# ---- BLEND within-sleeve: riempi lo sleeve ETH con EXIT-16 + un candidato decorrelato ----
print(" " + "-" * 100 + "\n BLEND within-sleeve (lo sleeve ETH = mix di 2 strategie, peso PORT06 invariato):")
blends = {
"50/50 EXIT16+varratio": (["EXIT16(live)", "varratio_gate"], [0.5, 0.5]),
"50/50 EXIT16+chopDonch": (["EXIT16(live)", "choppiness_donch"], [0.5, 0.5]),
"50/50 EXIT16+vrp": (["EXIT16(live)", "vrp_neg_dvol_low"], [0.5, 0.5]),
"70/30 EXIT16+vrp": (["EXIT16(live)", "vrp_neg_dvol_low"], [0.7, 0.3]),
"50/50 EXIT16put+vrp": (["EXIT16+put10%OTM", "vrp_neg_dvol_low"], [0.5, 0.5]),
"tri EXIT16put+vrp+chop": (["EXIT16+put10%OTM", "vrp_neg_dvol_low", "choppiness_donch"], [0.5, 0.25, 0.25]),
}
for name, (keys, wts) in blends.items():
try:
be = blend_equity([equ[k] for k in keys], wts)
m2 = dict(eq); m2["MR02_ETH"] = be
f_c, o_c = port_metrics(m2, ids)
rows.append((name, -1, f_c, o_c))
print(f" {name:<22s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f}{f_c['cagr']:>9.0f}% |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}{o_c['cagr']:>8.0f}%")
except Exception as ex:
print(f" {name:<22s} ERRORE: {ex}")
# riferimento ONESTO = EXIT-16 (config LIVE), non il canonico intrabar-SL
ex = next((r for r in rows if r[0] == "EXIT16(live)"), None)
f_l, o_l = (ex[2], ex[3]) if ex else (f_b, o_b)
print("\n " + "=" * 100)
print(f" GATE vs LIVE EXIT-16 (FULL {f_l['sharpe']:.2f}/{f_l['dd']:.2f} OOS {o_l['sharpe']:.2f}/{o_l['dd']:.2f}):")
print(" MIGLIORIA = nessuna metrica peggiora oltre il rumore E almeno una migliora (Sharpe +>=0.03 o DD -)")
print(" " + "-" * 100)
for name, ntr, f_c, o_c in rows:
if name.startswith("MR02recon") or name == "EXIT16(live)":
continue
dfs, dfd = f_c['sharpe'] - f_l['sharpe'], f_c['dd'] - f_l['dd']
dos, dod = o_c['sharpe'] - o_l['sharpe'], o_c['dd'] - o_l['dd']
no_worse = dfs >= -0.03 and dos >= -0.03 and dfd <= 0.05 and dod <= 0.03
better = dfs >= 0.03 or dos >= 0.03 or dfd <= -0.03 or dod <= -0.03
ok = no_worse and better
print(f" {name:<22s} ΔFULL Sh {dfs:+.2f} DD {dfd:+.2f} | ΔOOS Sh {dos:+.2f} DD {dod:+.2f} -> "
f"{'>>> MIGLIORIA' if ok else ('= pari' if no_worse else 'peggiora')}")
if __name__ == "__main__":
main()
@@ -0,0 +1,279 @@
export const meta = {
name: 'mr02eth-replace-search',
description: 'Cerca sostituto/miglioria a MR02/ETH: 112 strategie distinte su harness onesto + verifica avversariale',
phases: [
{ title: 'Generate', detail: '112 agenti, una strategia ETH distinta ciascuno, backtest onesto fee-aware OOS' },
{ title: 'Verify', detail: 'verifica avversariale dei survivor (look-ahead audit + re-run + robustezza)' },
],
}
// ---------------------------------------------------------------- baseline & recipe
const BASELINE = `BASELINE DA BATTERE (MR02/ETH sullo STESSO engine explore_lab, ETH 1h, fee 0.10% RT, lev 3x, pos 0.15):
- canonico (Donchian n=20, sl 2*ATR, max_bars 24, TP a centro canale): full Sharpe 7.49, OOS Sharpe 10.94, full DD 42%, OOS DD 18%, exposure 31%, 2392 trade, fee0.2% positivo.
- trend-filtrato (come sopra + salta se |close-EMA200|/ATR(14) > 3.0): full Sharpe 8.45, OOS Sharpe 11.96, full DD 25%, OOS DD 13%, exposure 16%, 1148 trade, fee0.2% positivo.
Il PROBLEMA di questo sleeve e' il DD/whipsaw (fada il trend al ribasso, poi shorta il rimbalzo). Un buon SOSTITUTO deve: battere OOS Sharpe ~12 E/O tagliare il full DD sotto ~20%, restando OOS positivo, sopravvivere a fee 0.20% RT, con la maggior parte degli anni 2021+ positivi. DD piu' basso a Sharpe comparabile e' GIA' valore. Anche un buon DIVERSIFICATORE (Sharpe decente + decorrelato, esposizione diversa) e' utile.`
const RECIPE = `Lavora in /opt/docker/PythagorasGoal. Testa UNA idea di strategia come candidata a SOSTITUIRE/MIGLIORARE MR02/ETH.
Usa l'harness ONESTO condiviso (no look-ahead, fee-aware, OOS). Scrivi UNO script self-contained in /tmp/cand_KEY.py (KEY = la tua key, univoca) ed eseguilo:
cd /opt/docker/PythagorasGoal && uv run python /tmp/cand_KEY.py
API (import from scripts.analysis.explore_lab):
- get_df(asset, tf) -> df [open,high,low,close,volume,timestamp,dt]. Usa asset="ETH", tf="1h" (puoi testare anche tf="4h").
- atr(df,n=14)->np.array ; ema(x,n)->np.array ; rsi(close,n=14)->np.array
- simulate(entries, df, fee_rt=0.001, lev=3.0, pos=0.15, split=-1) -> dict(trades,win,ret,dd,sharpe,yearly,exposure)
- evaluate(name, entries, df, fees=(0,0.0005,0.001,0.002)) -> dict(full,oos,sweep,sweep_oos,pos_yrs,n_yrs) e stampa una riga.
- robust(res)->bool.
Se la tua idea usa DVOL/funding/Hurst/fractali, puoi importare da scripts.analysis.regime_lab: load(asset,tf), regime_features(df), frac_features(df), load_features(asset,tf) (cache; vedi il file se serve). Se non sono disponibili, ripiega su feature calcolate dalle sole OHLCV. Se la tua idea e' shape/kNN, vedi scripts.analysis.shape_lab (analog_entries, analog_signals).
entries = list di {"i":int, "d":+1/-1, "max_bars":int, "tp":float|None, "sl":float|None}. L'ingresso esegue a close[i]. TP/SL sono intrabar al livello (SL valutato PRIMA del TP). split>0 nel simulate = solo entries con i>=split (OOS).
ANTI-LOOK-AHEAD (non negoziabile): direzione e prezzo alla barra i usano SOLO dati [0..i] (close[i] incluso). Mai usare high/low/close della barra i+ per decidere ingresso/direzione. Canali/rolling che devono escludere la barra corrente vanno .shift(1). Prezzo d'ingresso = close[i]. La famiglia squeeze e' stata scartata proprio per questo errore: NON ripeterlo.
${BASELINE}
Procedura: implementa l'idea, fai uno sweep PICCOLO (2-4 config di parametri), scegli la MIGLIORE per OOS Sharpe a parita' di: sopravvive a fee 0.20% RT e full DD non peggiore della baseline. Verifica l'assenza di look-ahead (perturba le barre future e controlla che gli entries non cambino, oppure argomenta perche' non c'e').
Restituisci SOLO il risultato strutturato della tua MIGLIORE config. Sii ONESTO: se non batte la baseline, riporta i numeri VERI (peggiori) — un risultato negativo e' informazione utile. NON inventare numeri: devono venire dall'esecuzione reale dello script.`
const SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['key','family','implemented','ran_ok','best_config','trades','exposure','full_sharpe','full_ret','full_dd','oos_sharpe','oos_ret','oos_dd','fee02_full','fee02_oos','pos_yrs','n_yrs','no_lookahead','beats_baseline','verdict','note'],
properties: {
key: { type: 'string' },
family: { type: 'string' },
implemented: { type: 'boolean' },
ran_ok: { type: 'boolean', description: 'lo script ha girato senza errori e i numeri vengono dall esecuzione reale' },
best_config: { type: 'string', description: 'parametri della config migliore' },
trades: { type: 'integer' },
exposure: { type: 'number', description: '% barre in posizione (full)' },
full_sharpe: { type: 'number' },
full_ret: { type: 'number', description: '% ritorno full' },
full_dd: { type: 'number', description: '% max drawdown full' },
oos_sharpe: { type: 'number' },
oos_ret: { type: 'number' },
oos_dd: { type: 'number' },
fee02_full: { type: 'number', description: 'ritorno % full a fee 0.20% RT' },
fee02_oos: { type: 'number', description: 'ritorno % OOS a fee 0.20% RT' },
pos_yrs: { type: 'integer', description: 'anni positivi' },
n_yrs: { type: 'integer' },
no_lookahead: { type: 'boolean' },
beats_baseline: { type: 'boolean', description: 'batte la baseline MR02/ETH trend-filtrata (OOS Sharpe ~12 e/o DD<20%)' },
verdict: { type: 'string', enum: ['substitute','improvement','diversifier','reject'] },
note: { type: 'string', description: '1-2 frasi: perche funziona o perche no, e l angolo distintivo' },
},
}
// ---------------------------------------------------------------- 112 idee distinte
// [key, family, idea]
const SPECS = [
// A. fade gated da regime (il fix del whipsaw)
['adx_gate_fade','gated-fade','Donchian/Bollinger fade su ETH ma SALTA quando ADX(14) e alto (regime trending). Calcola ADX causale; entra in fade solo se ADX<soglia (sweep 18/22/25). Tesi: evita di fadare i trend forti che fanno il whipsaw a MR02.'],
['kaufman_er_gate','gated-fade','Mean-reversion fade gated dal Kaufman Efficiency Ratio (|close-close[-n]| / somma|delta| su n barre). Fada solo se ER basso (mercato choppy/range), salta se ER alto (trending). Sweep n e soglia.'],
['hurst_gate_fade','gated-fade','Donchian fade ma entra solo se rolling-Hurst (causale, dalle close) < soglia anti-persistente (sweep 0.45/0.5/0.55). Usa frac_features da regime_lab se disponibile, altrimenti Hurst R/S manuale.'],
['varratio_gate_fade','gated-fade','Donchian fade gated dal variance-ratio (VR<1 = mean-reverting). Entra in fade solo quando VR(k) sotto soglia. Sweep k e soglia.'],
['rv_pct_gate_fade','gated-fade','Donchian fade ma salta quando la realized-vol percentile (rolling) e nel quartile alto: la vol estrema = crash/parabola dove la fade muore. Sweep finestra e soglia percentile.'],
['dvol_gate_fade','gated-fade','Bollinger/Donchian fade ETH gated dalla DVOL percentile (regime_lab.load+regime_features). Testa fade SOLO in DVOL bassa (calmo) vs alta. Riporta quale regime e migliore.'],
['vrp_gate_fade','gated-fade','Fade gated dal VRP (vol risk premium = dvol-rv) e dal livello DVOL. Test del finding FR01: edge su VRP<0 + DVOL bassa. Da regime_lab. Fada solo in quel regime.'],
['funding_gate_fade','gated-fade','Donchian fade gated dal funding (regime_lab): es. evita fade long quando funding molto negativo (panic). Sweep soglia funding_z.'],
['emaslope_gate_fade','gated-fade','Fade ma salta quando la pendenza di EMA200 (slope su k barre, normalizzata da ATR) e ripida: non fadare contro un trend strutturale. Sweep soglia slope.'],
['choppiness_gate_fade','gated-fade','Donchian fade gated dal Choppiness Index (alto=range, basso=trend). Entra solo se CI>soglia (range). Sweep finestra/soglia.'],
['rsq_gate_fade','gated-fade','Fade gated dall R^2 di una regressione lineare rolling sul log-prezzo: alto R^2 = trend pulito (salta), basso = range (fada). Sweep finestra/soglia.'],
['bbw_gate_fade','gated-fade','Bollinger fade gated dalla band-width (BBW): fada solo quando BBW in regime medio/alto ma non esplosivo. Sweep soglie BBW percentile.'],
['atr_pct_gate_fade','gated-fade','Donchian fade ma salta quando ATR-percentile rolling e estremo (>p80): la coda di vol e dove il whipsaw uccide. Sweep percentile.'],
['htf_align_fade','gated-fade','Fade ETH 1h ma NON contro il trend 4h: salta i long-fade se EMA20<EMA50 su 4h (downtrend) e i short-fade se uptrend 4h. Resample 4h dalla 1h. Fada solo allineato/neutro.'],
['asym_trenddist_fade','gated-fade','Donchian fade con soglie trend-distance ASIMMETRICHE long vs short, e cap piu stretto sul lato contro-trend HTF. Sweep coppie di soglie.'],
['btc_regime_fade','gated-fade','Fade ETH solo quando BTC e in regime range (BTC |close-EMA200|/ATR sotto soglia): se BTC sta trendando forte, anche ETH trenda e la fade muore. Trade ETH, gate su BTC.'],
// B. meccanismi mean-reversion alternativi
['rsi2_revert','mr-alt','RSI(2) di Connors: long quando RSI2<10, short quando RSI2>90, exit su RSI2 che torna a 50 o max_bars. Sweep soglie e max_bars su ETH 1h.'],
['rsi14_bands','mr-alt','RSI(14) revert con bande: long RSI<30, short RSI>70, TP al ritorno a 50 (proxy prezzo), SL ATR. Sweep.'],
['pctb_fade','mr-alt','Bollinger %B fade: long quando %B<0, short quando %B>1, TP a media, SL ATR. Sweep finestra/sigma/max_bars. Confronta con MR01.'],
['zscore_price_fade','mr-alt','Z-score del prezzo vs SMA(n): entra quando |z|>soglia contro il movimento, TP a z=0 (SMA), SL ATR. Sweep n/soglia. Versione simmetrica del DIP.'],
['zscore_ret_fade','mr-alt','Z-score del rendimento di barra (MR07-like) ma ri-tunato per ETH: |z|>soglia su finestra n, exit in multipli ATR. Sweep n/soglia/exit.'],
['vwap_revert','mr-alt','Reversion a VWAP rolling (volume-weighted): entra quando il prezzo devia >k*sigma dal VWAP, TP al VWAP. Sweep finestra/k.'],
['keltner_fade_eth','mr-alt','Keltner channel fade ri-tunato per ETH (EMA +/- m*ATR): fada la rottura della banda Keltner, TP a EMA. Sweep m/finestra. MR03 era debole su BTC ma ETH puo differire.'],
['cci_revert','mr-alt','CCI(n) extreme revert: long CCI<-100, short CCI>+100, exit su CCI->0 o max_bars. Sweep.'],
['williamsr_revert','mr-alt','Williams %R revert: long %R<-80, short %R>-20, TP a media canale, SL ATR. Sweep.'],
['stoch_revert','mr-alt','Stochastic %K/%D extreme revert su ETH: long quando %K<20 e cross-up, short %K>80 cross-down. Sweep finestre.'],
['connors_rsi','mr-alt','Connors RSI (RSI3 del prezzo + RSI2 della streak + percentrank del rendimento) extreme revert. Sweep soglie.'],
['anchored_vwap_revert','mr-alt','Reversion a VWAP ancorato all ultimo swing-low/high (Williams): entra quando il prezzo si allontana >k*ATR dall AVWAP, TP all AVWAP.'],
['median_z_fade','mr-alt','Z robusto: (close - rolling median) / rolling MAD. Fada |z|>soglia, TP a mediana. Piu robusto agli outlier della SMA-z. Sweep.'],
['pctrank_fade','mr-alt','Percent-rank del close nella finestra rolling: long se rank<0.05, short se rank>0.95, TP al rank 0.5 (proxy). Sweep finestra.'],
['double_bollinger','mr-alt','Doppia Bollinger: entra fade alla banda esterna (2.5-3 sigma), esce alla banda interna (1 sigma) invece che alla media. Sweep sigma esterna/interna.'],
['fade_to_ema','mr-alt','Donchian/Bollinger fade ma TP all EMA(n) invece che SMA/centro-canale (target dinamico che segue il trend). Sweep n EMA.'],
['nbar_lowhigh_rsi','mr-alt','Entra long su nuovo minimo a N barre CON RSI<35 (doppia conferma di esaurimento), short speculare. TP a media, SL ATR. Sweep N.'],
['overshoot_swing_fade','mr-alt','Fada quando il prezzo supera lo swing-high/low precedente (Williams) di k*ATR e poi la barra chiude rientrando: false-breakout fade. Sweep k.'],
// C. exit migliori sulla Donchian fade (stesso ingresso)
['tight_sl_confirm','exit','Donchian fade con SL piu stretto (1.0/1.5 ATR) ma close-confirm (esci solo se il close sfonda SL-0.5ATR). Replica EXIT-16. Sweep buffer.'],
['fast_tp_partial','exit','Donchian fade con TP a frazione del canale (0.25/0.5 width) invece che al centro: incassa prima il rimbalzo, riduce il tempo a rischio. Sweep frazione.'],
['short_timestop','exit','Donchian fade con time-stop corto (max_bars 6/8/12 invece di 24): esce prima che il whipsaw maturi. Sweep max_bars.'],
['atr_trailing_exit','exit','Donchian fade con uscita a ATR-trailing dopo l ingresso (trail il massimo favorevole di k*ATR). Sweep k. (la ricerca dice che il trailing spesso fallisce: verifica onestamente).'],
['breakeven_stop','exit','Donchian fade: dopo che il trade va in utile di +x*ATR, sposta lo stop a breakeven. Sweep x.'],
['rsi_exit','exit','Donchian fade con exit quando RSI torna oltre 50 (mean ripristinata) invece del TP fisso. Sweep.'],
['return_to_ema_exit','exit','Donchian fade con exit quando il prezzo ri-tocca l EMA(50) (ritorno alla media mobile) o max_bars. Sweep EMA.'],
['volscaled_maxbars','exit','Donchian fade con max_bars scalato sulla vol: meno barre in alta-vol, piu in bassa-vol. Sweep mapping.'],
['chandelier_exit','exit','Donchian fade con Chandelier exit (max favorevole - k*ATR). Sweep k.'],
['tp_opposite_band','exit','Donchian fade con TP alla banda OPPOSTA (reversione completa del canale) invece del centro. Piu ambizioso, meno frequente. Sweep.'],
// D. trend-following su ETH (prende cio che la fade perde)
['ema_cross_4h','trend','EMA20/EMA100 cross su ETH 4h, long quando EMA20>EMA100, flat altrimenti (TR01-style su ETH solo). Niente fade. Valuta come diversificatore direzionale.'],
['donchian_breakout','trend','Donchian BREAKOUT trend (opposto del fade): long su rottura del massimo a N barre, stop sotto il minimo. La ricerca dice che i breakout crypto rientrano: verifica ONESTAMENTE se su ETH regge netto fee.'],
['macd_trend','trend','MACD(12,26,9) trend-following su ETH 1h/4h: long su cross rialzista sopra zero, exit su cross opposto. Sweep TF.'],
['dual_ma_atr','trend','Dual-MA (fast/slow) con filtro ATR sul gap: entra trend solo se separazione MA > k*ATR (evita whipsaw delle MA). Sweep.'],
['supertrend','trend','Supertrend (ATR band) su ETH: segui il flip. Sweep multiplier/periodo.'],
['momentum_sign','trend','Momentum semplice: long se rendimento a H barre > 0 (12h/24h/48h), tieni H barre. Time-series momentum su ETH. Sweep H.'],
['tsmom_eth','trend','TSMOM multi-orizzonte (3/6/12 mesi sul daily resample di ETH) con risk-off se sotto SMA100. Solo ETH. Valuta come diversificatore.'],
['breakout_vol_confirm','trend','Channel breakout con conferma di volume (rottura valida solo se volume>media*k). Sweep.'],
['heikin_trend','trend','Heikin-Ashi trend: long su serie di candele HA verdi, exit su prima rossa. Costruisci HA causale. Sweep.'],
['triple_screen','trend','Triple-screen: trend 4h (EMA) definisce il bias, ingresso 1h su pullback nella direzione del trend (no contro-trend). Sweep.'],
// E. volatilita
['squeeze_expansion','vol','Bollinger-squeeze -> espansione, ma ingresso ONESTO: entra solo DOPO che la barra di espansione e CHIUSA, nella sua direzione, a close[i] (non anticipare). Verifica se l edge sparisce come per la famiglia squeeze scartata.'],
['atr_breakout','vol','ATR breakout: entra quando |close-close[-1]|>k*ATR nella direzione del movimento (continuazione) OPPURE contro (reversione) — testa entrambe e riporta quale regge. Sweep k.'],
['volofvol_revert','vol','Mean-reversion della vol-of-vol: quando la realized-vol fa uno spike estremo (z alto), fada il prezzo (gli spike di vol = capitolazione che rimbalza). Sweep.'],
['or_breakout_daily','vol','Opening-range breakout: definisci il range delle prime k ore UTC del giorno, entra sulla rottura con SL all altro lato. Sweep k. Trade ETH 1h.'],
['rv_regime_switch','vol','Switch fade/trend in base al regime di realized-vol: fade in bassa-vol (range), trend-follow in alta-vol. Una strategia adattiva. Sweep soglia.'],
['keltner_squeeze','vol','Keltner-inside-Bollinger squeeze: rileva la compressione, poi entra sulla direzione della prima rottura confermata (close oltre la banda). Onesto. Sweep.'],
// F. cross-asset / relative
['btc_leadlag','cross','BTC lead-lag: il rendimento di BTC alla barra i predice ETH alla barra i+1. Entra ETH a close[i] nella direzione del segnale BTC (solo dati fino a i). Sweep soglia/horizon.'],
['ethbtc_ratio_revert','cross','Reversione del ratio ETH/BTC: quando il log-ratio devia >z*sigma dalla sua media, prendi posizione DIREZIONALE su ETH (long ETH se ratio depresso). Sweep n/z.'],
['eth_beta_residual','cross','Residuo beta: regredisci ETH su BTC (beta rolling causale), fada il residuo (ETH ricco/povero vs BTC). Posizione direzionale ETH. Sweep finestra.'],
['btc_trend_eth_fade','cross','Fade ETH SOLO quando BTC e in range (gia simile a btc_regime_fade ma qui il gate e la pendenza EMA di BTC + la sua DVOL). Combina due gate BTC.'],
['eth_vs_basket_mom','cross','Cross-sectional: ETH momentum relativo vs basket (BTC,BNB,SOL,XRP). Long ETH se sovra-performa il basket di recente (continuazione) o sotto-performa (reversione) — testa entrambe.'],
['funding_spread','cross','Spread di funding ETH-BTC (regime_lab): quando il funding di ETH e estremo vs BTC, prendi posizione di reversione su ETH. Sweep.'],
// G. ML / shape
['sh01_logit_eth','ml','LogisticRegression stile SH01 su ETH: 17 feature di forma (body/shadow, rendimenti, pendenza, curvatura, pos max/min, RSI, estensione) su W24, predice segno a H12, walk-forward causale, entra se proba>0.58. Riusa scripts.analysis.shape_ml_research se possibile. ETH-specifico.'],
['analog_knn_eth','ml','Analog kNN sulla forma (shape_lab.analog_entries) su ETH: W/H/K sweep stretto, agree>0.6. Causale (KDTree ribuilt). Valuta come diversificatore.'],
['gbm_features_eth','ml','GradientBoostingClassifier walk-forward su feature ingegnerizzate (RSI, ATR-norm returns, distance-from-EMA, vol, Donchian-pos) per predire il segno a H barre. Train solo sul passato. Sweep H/threshold.'],
['logit_regime_mom','ml','LogReg walk-forward su feature di regime+momentum (DVOL, funding, ER, rendimenti multi-h) per predire direzione ETH a H. Da regime_lab. Sweep.'],
['rf_direction','ml','RandomForest walk-forward direzione ETH su feature tecniche. Train espandente. Sweep n_estimators/H/threshold. Onesto (no leakage temporale).'],
['ridge_return_sign','ml','Ridge regression walk-forward che prevede il rendimento a H barre; entra sul segno se |pred|>soglia. Feature: lag returns, RSI, ATR, distance-EMA. Sweep.'],
// H. microstruttura / calendario
['intraday_seasonality','micro','Stagionalita oraria: trova le ore UTC con edge di reversione/continuazione su ETH (split-half per evitare overfit), trada solo quelle. Onesto su OOS.'],
['gap_fade','micro','Gap fade: quando una barra apre/chiude con un gap >k*ATR vs la precedente, fada il gap (reversione). Sweep k.'],
['hourofday_fade','micro','Fade Donchian condizionato all ora del giorno: attiva la fade solo nelle ore storicamente mean-reverting. Verifica OOS (rischio overfit alto: sii severo).'],
['dayofweek','micro','Effetto giorno-della-settimana su ETH: posizioni condizionate al weekday (es. weekend illiquido). Severo su OOS (probabile rumore).'],
['funding_settlement','micro','Timing settlement funding (00/08/16 UTC): fade del movimento attorno al settlement (over-reaction pre/post funding). Sweep finestra.'],
['large_candle_reversal','micro','Reversione dopo candela estesa: se |close-open|>k*ATR (barra-shock), entra contro alla chiusura della barra successiva. Sweep k.'],
['three_bar_reversal','micro','Pattern 3-barre: 3 chiusure consecutive nello stesso verso poi entra contro (esaurimento). Sweep n-barre/soglia.'],
['consecutive_exhaustion','micro','N candele rosse consecutive -> long (e speculare). Conta le streak causalmente, entra all N-esima. Sweep N. TP a media, SL ATR.'],
// I. combinazioni / ensemble
['donchian_rsi_confirm','combo','Donchian fade CON conferma RSI (long solo se RSI<40 oltre alla rottura del canale): doppia conferma per ridurre i falsi segnali nei trend.'],
['bb_donchian_confluence','combo','Confluenza Bollinger + Donchian: fada solo quando ENTRAMBE le bande sono rotte simultaneamente (segnale piu forte, meno frequente, meno whipsaw).'],
['volspike_fade','combo','Fade solo su spike di volume (volume>media*k): la capitolazione su volume rimbalza meglio del drift silenzioso. Sweep k.'],
['funding_tailwind_fade','combo','Donchian fade con vento di funding: fada gli short (entra long) solo se funding positivo (i long pagano = pressione di unwind ribassista esaurita) e viceversa. Da regime_lab.'],
['regime_switch_classifier','combo','Classificatore di regime (trend vs range via ADX/ER/Hurst) che decide SE fadare o trend-followare ad ogni barra: una sola strategia adattiva. Sweep soglie del classificatore.'],
['vote_ensemble_mr','combo','Ensemble a voto di 3 segnali MR indipendenti (Bollinger, Donchian, z-return): entra solo se >=2 concordano sulla direzione. Riduce i falsi.'],
['adaptive_donchian','combo','Donchian a lookback ADATTIVO: n scalato sulla vol (canale piu lungo in alta-vol, piu corto in bassa-vol). Sweep mapping vol->n.'],
// J. statistico-robusto
['ou_halflife','stat','Ornstein-Uhlenbeck: stima half-life e media di reversione (rolling, causale), entra quando la deviazione dalla media OU supera z*sigma, exit a half-life. Sweep.'],
['kalman_meanrev','stat','Kalman filter sul prezzo (livello+trend) come media dinamica; fada la deviazione dal livello stimato. Causale (filtro one-pass). Sweep Q/R.'],
['quantile_band_fade','stat','Bande a quantili rolling (5/95 percentile su finestra n) invece di sigma: fada la rottura del quantile, TP al quantile 50. Piu robusto a code fat. Sweep n/quantili.'],
['theilsen_residual','stat','Detrend Theil-Sen rolling (pendenza robusta), fada il residuo quando estremo. Sweep finestra.'],
['hampel_outlier_fade','stat','Hampel filter: identifica gli outlier (|x-median|>k*MAD) e fada l outlier (reversione). Sweep k/finestra.'],
['rollreg_residual_z','stat','Regressione lineare rolling (prezzo~tempo), z del residuo; fada |z|>soglia, TP a residuo 0. Sweep finestra/soglia.'],
['dominant_cycle_revert','stat','Stima del ciclo dominante (autocorrelazione/zero-cross rolling), entra in reversione in fase con il ciclo. Causale. Sweep.'],
// K. ulteriori gate/varianti per arrivare a 112
['choppiness_donchian','gated-fade','Choppiness Index + Donchian: fada solo se CI>61.8 (range conclamato). Variante severa di choppiness_gate_fade con soglia fissa Fibonacci. Sweep secondario su n.'],
['er_bollinger','gated-fade','Efficiency-Ratio gate su Bollinger fade (non Donchian): fada la banda solo se ER<soglia. Confronta con kaufman_er_gate (li su Donchian).'],
['adx_rsi2','combo','ADX<20 (range) come gate per un RSI(2) extreme-revert: solo MR in regime non-trending. Sweep.'],
['hurst045_only','gated-fade','Fade attiva SOLO quando rolling-Hurst<0.45 (forte anti-persistenza): regime ultra-mean-reverting. Pochi trade, alta qualita? Verifica esposizione/OOS.'],
['dvol_low_fade','gated-fade','Fade ETH solo quando DVOL < mediana (regime calmo): la calma e dove la reversione paga e il whipsaw e raro. Da regime_lab. Sweep soglia.'],
['vrp_neg_dvol_low','gated-fade','Replica diretta del finding FR01 su ETH: fade attiva su VRP<0 E DVOL bassa. Da regime_lab. Misura Sharpe/DD/esposizione e decorrelazione.'],
['funding_extreme_fade','gated-fade','Carry/funding reversion: quando il funding_z e estremo (>|2|), fada il prezzo nella direzione dell unwind atteso. Da regime_lab. Sweep.'],
['range_day_fade','gated-fade','Filtro range-day: fada solo se il range della sessione corrente (rolling 24h) < media*soglia (giornata compressa = reversione affidabile). Sweep.'],
['mtf_donchian','combo','Donchian multi-timeframe: ingresso su rottura del canale 1h ma con canale di riferimento 4h (TP al centro del canale 4h). Sweep n1/n4.'],
['volweighted_fade','combo','Fade pesata dal volume: ingresso solo se il volume della barra di segnale e nel quartile alto (conferma di partecipazione). Sweep.'],
['false_break_fade','mr-alt','Fade del falso breakout: il prezzo rompe il canale Donchian ma la barra SUCCESSIVA chiude di nuovo dentro -> entra contro la rottura a quella chiusura. Onesto. Sweep n.'],
['band_walk_exhaustion','mr-alt','Esaurimento del band-walk: dopo k chiusure consecutive fuori dalla banda Bollinger (band walking), fada il rientro alla prima chiusura dentro. Sweep k/sigma.'],
['pivot_revert','stat','Reversione ai pivot floor classici (P, R1/R2, S1/S2 calcolati sul giorno precedente): fada il tocco di R2/S2 verso P. Causale (pivot da ieri). Sweep.'],
['fractal_swing_revert','stat','Reversione agli swing di Williams (frattali a 5 barre confermati con ritardo): fada il tocco di un nuovo frattale verso la media. Causale (conferma ritardata). Sweep.'],
['lrc_fade','stat','Linear Regression Channel: canale di regressione rolling +/- 2 std del residuo; fada il tocco dei bordi verso la linea centrale. Sweep finestra.'],
]
// ---------------------------------------------------------------- esecuzione
phase('Generate')
log(`Lancio ${SPECS.length} agenti, una strategia ETH distinta ciascuno (harness onesto fee-aware OOS)`)
const results = await parallel(SPECS.map(([key, family, idea]) => () =>
agent(
`${RECIPE}\n\n========\nLA TUA IDEA — key="${key}", family="${family}":\n${idea}\n\nUsa /tmp/cand_${key}.py come file. Restituisci il risultato strutturato (campi key e family valorizzati con i tuoi).`,
{ label: `gen:${key}`, phase: 'Generate', schema: SCHEMA }
).then(r => r ? { ...r, key, family } : null)
))
const ran = results.filter(Boolean)
log(`${ran.length}/${SPECS.length} agenti rientrati. Filtro i survivor per la verifica avversariale.`)
// survivor: ha girato davvero, e o batte la baseline o e' chiaramente interessante
// (OOS Sharpe alto, oppure DD ben piu basso a Sharpe decente), e regge la fee 0.2%.
const survivors = ran.filter(r =>
r.ran_ok && r.implemented && r.no_lookahead &&
r.full_ret > 0 && r.oos_ret > 0 && r.fee02_oos > 0 && r.fee02_full > 0 &&
(
r.beats_baseline ||
r.oos_sharpe >= 11.0 ||
(r.full_dd <= 20.0 && r.oos_sharpe >= 8.0) ||
(r.full_dd <= 12.0 && r.oos_sharpe >= 5.0) // diversificatore a basso DD
)
)
// ordina per una metrica composita: premia OOS Sharpe e penalizza DD
const score = r => r.oos_sharpe - 0.10 * r.full_dd
survivors.sort((a, b) => score(b) - score(a))
const toVerify = survivors.slice(0, 24) // cap la verifica ai 24 migliori
log(`${survivors.length} survivor; verifico avversarialmente i top ${toVerify.length}`)
phase('Verify')
const VSCHEMA = {
type: 'object', additionalProperties: false,
required: ['key','confirmed','reproduced','lookahead_found','overfit_risk','full_sharpe','full_dd','oos_sharpe','oos_dd','beats_mr02eth','note'],
properties: {
key: { type: 'string' },
confirmed: { type: 'boolean', description: 'edge reale, eseguibile, fee-robusto, dopo audit indipendente' },
reproduced: { type: 'boolean', description: 'ho ri-eseguito e ho ottenuto numeri coerenti (entro ~15%)' },
lookahead_found: { type: 'boolean' },
overfit_risk: { type: 'string', enum: ['low','med','high'] },
full_sharpe: { type: 'number' },
full_dd: { type: 'number' },
oos_sharpe: { type: 'number' },
oos_dd: { type: 'number' },
beats_mr02eth: { type: 'boolean', description: 'batte davvero MR02/ETH (OOS Sharpe>=~12 a DD<=25, o DD<20 a Sharpe comparabile)' },
note: { type: 'string' },
},
}
const verified = await parallel(toVerify.map(r => () =>
agent(
`Verifica AVVERSARIALE di una strategia candidata a sostituire MR02/ETH. Lavora in /opt/docker/PythagorasGoal con l'harness onesto scripts.analysis.explore_lab (get_df, simulate, evaluate, robust, atr, ema, rsi). Default: il tuo compito e' SMENTIRE.\n\n${BASELINE}\n\nCandidata key="${r.key}" (family ${r.family}).\nConfig dichiarata: ${r.best_config}\nNumeri dichiarati: full Sharpe ${r.full_sharpe}, OOS Sharpe ${r.oos_sharpe}, full DD ${r.full_dd}%, OOS DD ${r.oos_dd}%, trade ${r.trades}, exposure ${r.exposure}%, fee0.2% full ${r.fee02_full}, fee0.2% OOS ${r.fee02_oos}.\nDescrizione idea: la trovi rifacendola da zero.\n\nRI-IMPLEMENTA tu stesso la strategia (in /tmp/verify_${r.key}.py) sulla SUA config dichiarata, eseguila sull'harness, e controlla: (1) LOOK-AHEAD — l'ingresso/direzione usa solo dati fino a close[i]? Perturba le barre future e verifica che gli entries non cambino. (2) RIPRODUCIBILITA — i numeri tornano entro ~15%? (3) ROBUSTEZZA/OVERFIT — regge a fee 0.20% RT, a piccole variazioni dei parametri (plateau, non picco isolato), ed e' positiva nella maggior parte degli anni 2021+? (4) batte DAVVERO MR02/ETH (OOS Sharpe e/o DD)?\n\nSe non riesci a riprodurre o trovi look-ahead, confirmed=false. Riporta i TUOI numeri ri-eseguiti, non quelli dichiarati. Sii severo: meglio bocciare un falso positivo che promuovere un artefatto.`,
{ label: `verify:${r.key}`, phase: 'Verify', schema: VSCHEMA }
).then(v => v ? { ...v, key: r.key, family: r.family, gen: r } : null)
))
const vok = verified.filter(Boolean)
const confirmed = vok.filter(v => v.confirmed && !v.lookahead_found && v.beats_mr02eth)
confirmed.sort((a, b) => (b.oos_sharpe - 0.1 * b.full_dd) - (a.oos_sharpe - 0.1 * a.full_dd))
log(`Verifica completata: ${confirmed.length} confermati che battono MR02/ETH`)
return {
n_specs: SPECS.length,
n_ran: ran.length,
n_survivors: survivors.length,
n_verified: vok.length,
n_confirmed: confirmed.length,
confirmed,
all_survivors: survivors.map(s => ({ key: s.key, family: s.family, best_config: s.best_config,
full_sharpe: s.full_sharpe, full_dd: s.full_dd, oos_sharpe: s.oos_sharpe, oos_dd: s.oos_dd,
exposure: s.exposure, trades: s.trades, fee02_oos: s.fee02_oos, verdict: s.verdict, note: s.note })),
verified: vok.map(v => ({ key: v.key, confirmed: v.confirmed, reproduced: v.reproduced,
lookahead_found: v.lookahead_found, overfit_risk: v.overfit_risk, beats_mr02eth: v.beats_mr02eth,
full_sharpe: v.full_sharpe, full_dd: v.full_dd, oos_sharpe: v.oos_sharpe, oos_dd: v.oos_dd, note: v.note })),
}
+259
View File
@@ -0,0 +1,259 @@
"""Validazione out-of-sample fee-aware di tutte le strategie live.
Per ognuna delle 6 config in strategies.yml:
- split temporale held-out (train = primi (1-test_frac), test = ultimo test_frac)
- ML01 (SignalEngine): allena sul train, predice sul test (come il worker live)
- rule-based: i segnali sono causali, si valutano quelli nella finestra test
- simulazione fedele al worker live: una posizione per volta (non-overlap),
uscita a `hold` barre o stop a -2%, fee round-trip e leva inclusi
Stampa, per ogni config: numero trade nel test, win% lordo e netto, return netto,
costo commissioni, e confronto lordo-vs-netto per isolare l'impatto delle fee.
Usa i parquet locali (data/raw), nessuna chiamata di rete.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.strategy_loader import load_strategy
from src.live.signal_engine import SignalEngine, keltner_ratio, build_features
TEST_FRAC = 0.30
STOP_PCT = -0.02
def simulate(entries: list[tuple[int, int]], close: np.ndarray, hold: int,
fee_rt: float, lev: float, pos: float,
initial: float = 1000.0, entry_offset: int = 0) -> dict:
"""FSM fedele al worker live: non-overlap, hold N barre o stop -2%.
entry_offset: 0 = ingresso a close[i] (worker live); 1 = close[i-1]
(convenzione del backtest storico, che conosce la direzione di barra i).
"""
n = len(close)
capital = peak = initial
max_dd = 0.0
fees_eur = gross_eur = 0.0
wins_gross = wins_net = n_trades = 0
last_exit = -1
for i, d in entries:
e = i - entry_offset
if e <= last_exit or e < 0 or e + 1 >= n:
continue
entry = close[e]
exit_price = close[min(e + hold, n - 1)]
for k in range(1, hold + 1):
j = e + k
if j >= n:
exit_price = close[n - 1]
break
if k < hold and (close[j] - entry) / entry * d <= STOP_PCT:
exit_price = close[j]
break
if k == hold:
exit_price = close[j]
actual = (exit_price - entry) / entry * d # movimento prezzo * direzione (no leva)
gross = actual * lev
fee = fee_rt * lev
net = gross - fee
cap_before = capital
capital = max(cap_before + cap_before * pos * net, 10.0)
gross_eur += cap_before * pos * gross
fees_eur += cap_before * pos * fee
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
n_trades += 1
wins_gross += actual > 0
wins_net += net > 0
last_exit = e + hold
return {
"trades": n_trades,
"win_gross": wins_gross / n_trades * 100 if n_trades else 0.0,
"win_net": wins_net / n_trades * 100 if n_trades else 0.0,
"net_return_pct": (capital / initial - 1) * 100,
"net_eur": capital - initial,
"gross_eur": gross_eur,
"fees_eur": fees_eur,
"final_capital": capital,
"max_dd": max_dd * 100,
}
def rule_entries(name: str, df: pd.DataFrame, params: dict, split: int) -> list[tuple[int, int]]:
strat = load_strategy(name)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
return [(s.idx, s.direction) for s in sigs if s.idx >= split]
def ml_entries(df: pd.DataFrame, params: dict, split: int, hold: int) -> tuple[list[tuple[int, int]], dict]:
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
ml_thr = params.get("ml_threshold", 0.70)
eng = SignalEngine(bb_w=bb_w, sq_thr=sq_thr, ml_thr=ml_thr)
train_res = eng.train(df.iloc[:split].reset_index(drop=True), lookahead=hold)
if not eng.trained:
return [], train_res
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
kcr = keltner_ratio(close, high, low, bb_w)
up_idx = list(eng.model.classes_).index(1)
entries: list[tuple[int, int]] = []
in_sq = False
sq_start = 0
for i in range(bb_w + 1, n):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq, sq_start = True, i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < eng.min_squeeze_bars or i < split or i + hold >= n:
continue
avg_vol = float(np.mean(volume[sq_start:i]))
feats = build_features(df, i, dur, avg_vol, kcr[i])
if feats is None:
continue
p_up = eng.model.predict_proba(eng.scaler.transform(feats.reshape(1, -1)))[0][up_idx]
if p_up >= ml_thr:
entries.append((i, 1))
elif p_up <= (1 - ml_thr):
entries.append((i, -1))
return entries, train_res
def squeeze_releases(df: pd.DataFrame, bb_w: int, sq_thr: float, min_dur: int,
split: int) -> list[int]:
"""Indici delle barre di rilascio squeeze nella finestra test (idx >= split)."""
close = df["close"].values
high = df["high"].values
low = df["low"].values
kcr = keltner_ratio(close, high, low, bb_w)
rels: list[int] = []
in_sq = False
sq_start = 0
for i in range(bb_w + 1, len(df)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq, sq_start = True, i
elif not is_sq and in_sq:
in_sq = False
if i - sq_start >= min_dur and i >= split:
rels.append(i)
return rels
def honest_entries(df: pd.DataFrame, rels: list[int], rule: str, mom: int = 4) -> list[tuple[int, int]]:
"""Direzione da regole honest (solo dati <= i-1) o baseline breakout.
breakout: sign(close[i]-close[i-1]) -> conoscibile solo a close[i] (= live attuale)
premom: sign(close[i-1]-close[i-1-mom]) -> trend pre-release, 100% honest
fade: -sign(close[i]-close[i-1]) -> mean-reversion del breakout
"""
close = df["close"].values
out: list[tuple[int, int]] = []
for i in rels:
if i - 1 - mom < 0:
continue
if rule == "premom":
d = np.sign(close[i - 1] - close[i - 1 - mom])
elif rule == "fade":
d = -np.sign(close[i] - close[i - 1])
else: # breakout
d = np.sign(close[i] - close[i - 1])
if d != 0:
out.append((i, int(d)))
return out
def main():
cfg = yaml.safe_load((PROJECT_ROOT / "strategies.yml").read_text())
defaults = cfg.get("defaults", {})
hold = defaults.get("hold_bars", 3)
lev = defaults.get("leverage", 3)
fee_rt = 0.002
fee_grid = [0.0, 0.0005, 0.001, 0.0015, 0.002]
# ---- (b) SENSIBILITA' ALLE FEE (config live, ingresso close[i]) ----
print("=" * 104)
print(f" (b) SENSIBILITA' ALLE FEE — config live, ingresso close[i] | OOS {int(TEST_FRAC*100)}% | hold={hold} leva={lev}x")
print("=" * 104)
print(f" {'Strategia':<26s}{'Asset':>5s}{'Trd':>5s}{'Lordo€':>9s}"
+ "".join(f"{f'{f*100:.2f}%':>10s}" for f in fee_grid))
print(" " + "-" * 100)
for entry in cfg.get("strategies", []):
if not entry.get("enabled", True):
continue
name, asset, tf = entry["name"], entry["asset"], entry["tf"]
pos = entry.get("position_size", defaults.get("position_size", 0.15))
params = dict(entry.get("params", {}))
params["asset"], params["tf"] = asset, tf
df = load_data(asset, tf).reset_index(drop=True)
split = int(len(df) * (1 - TEST_FRAC))
close = df["close"].values
entries = (ml_entries(df, params, split, hold)[0] if name.startswith("ML01")
else rule_entries(name, df, params, split))
gross = simulate(entries, close, hold, 0.0, lev, pos)["net_eur"]
rets = [simulate(entries, close, hold, f, lev, pos)["net_return_pct"] for f in fee_grid]
print(f" {name:<26s}{asset:>5s}{len(entries):>5d}{gross:>+9.0f}"
+ "".join(f"{r:>+10.1f}" for r in rets))
print(" " + "-" * 100)
print(" Colonne = Ret% netto al variare della fee RT. 0.00% isola l'edge puro (senza costi).")
print(" Deribit perp reale: taker ~0.10% RT, maker ~0%. Il modello live usa 0.20% RT.")
# ---- (a) HONEST-ENTRY squeeze: direzione decisa <= i-1, ingresso close[i] ----
print("\n" + "=" * 104)
print(f" (a) HONEST-ENTRY squeeze (bb14 sq0.8 dur>=5) — ingresso close[i], fee={fee_rt*100:.1f}% RT")
print("=" * 104)
print(f" {'Asset':>5s}{'Regola direzione':>20s}{'Trd':>6s}{'Win%g':>8s}{'Win%n':>8s}{'Netto€':>9s}{'Ret%':>9s}{'DD%':>7s}")
print(" " + "-" * 100)
rules = [("breakout (=live)", "breakout"), ("pre-trend mom4", "premom"),
("pre-trend mom8", "premom8"), ("fade breakout", "fade")]
for asset in ["BTC", "ETH"]:
df = load_data(asset, "15m").reset_index(drop=True)
split = int(len(df) * (1 - TEST_FRAC))
close = df["close"].values
rels = squeeze_releases(df, 14, 0.8, 5, split)
for label, rule in rules:
mom = 8 if rule == "premom8" else 4
ents = honest_entries(df, rels, "premom" if rule == "premom8" else rule, mom=mom)
r = simulate(ents, close, hold, fee_rt, lev, 0.15)
print(f" {asset:>5s}{label:>20s}{r['trades']:>6d}{r['win_gross']:>8.1f}"
f"{r['win_net']:>8.1f}{r['net_eur']:>+9.0f}{r['net_return_pct']:>+9.1f}{r['max_dd']:>7.1f}")
print(" " + "-" * 100)
print(" pre-trend = direzione dal trend PRIMA del rilascio (solo dati <= i-1): 100% honest.")
print(" Se nessuna regola honest batte ~breakeven, non esiste edge direzionale tradeable.")
if __name__ == "__main__":
main()
+250
View File
@@ -0,0 +1,250 @@
"""Harness ONESTO per overlay di OPZIONI (protezione) sulle strategie ETH.
Vincolo noto (analisi ARGO/GEX 2026-06-01): lo storico per-strike dell'OI/prezzi
opzioni NON e' gratuito -> non backtestabile. MA la DVOL di Deribit (indice di vol
implicita ETH, annualizzato %) e' storica e gratuita in data/regime/eth_dvol.parquet
(oraria 2021-03 -> 2026-06). Quindi prezziamo le opzioni SINTETICAMENTE con
Black-Scholes(spot, DVOL) -> backtest del COSTO dell'overlay (premio) vs il payoff.
Approssimazione dichiarata (e DELIBERATAMENTE conservativa, bias CONTRO le opzioni):
- niente skew: i put reali costano piu' dell'ATM-IV -> applichiamo `skew_mult>=1`
alla sigma dei put (rincara il premio).
- all'uscita anticipata l'opzione vale solo l'INTRINSECO (nessun rebate di time-
value) -> sotto-stima il beneficio dell'hedge.
- niente costi di fill/liquidita' delle opzioni oltre lo skew_mult.
Se sotto queste ipotesi pessimistiche l'overlay MIGLIORA il rischio/rendimento,
l'edge e' robusto. I numeri sono INDICATIVI, non eseguibili come i perp.
Convenzione coerente con explore_lab: ret per-notional, lev 3x, pos 0.15, fee 0.10% RT.
L'opzione copre lo STESSO notional della posizione (lev*pos*cap), quindi il suo P&L
come frazione entra in ret' = ret_base + lev*(payoff - premio)/entry.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df, atr, ema, rsi, FEE_RT, LEV, POS, OOS_FRAC # noqa
HOURS_YEAR = 24 * 365.0
# --------------------------- Black-Scholes (r=0) ---------------------------
def _ncdf(x: float) -> float:
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
def bs_put(S: float, K: float, T: float, sigma: float) -> float:
"""Prezzo put europea, r=0. T in anni, sigma annualizzata (frazione)."""
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
return max(K - S, 0.0)
sd = sigma * math.sqrt(T)
d1 = (math.log(S / K) + 0.5 * sigma * sigma * T) / sd
d2 = d1 - sd
return K * _ncdf(-d2) - S * _ncdf(-d1)
def bs_call(S: float, K: float, T: float, sigma: float) -> float:
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
return max(S - K, 0.0)
sd = sigma * math.sqrt(T)
d1 = (math.log(S / K) + 0.5 * sigma * sigma * T) / sd
d2 = d1 - sd
return S * _ncdf(d1) - K * _ncdf(d2)
# --------------------------- DVOL allineata (causale) ---------------------------
def dvol_for(df: pd.DataFrame, asset: str = "ETH") -> np.ndarray:
"""DVOL (annualizzata, FRAZIONE es. 0.70) allineata causalmente ai bar di df.
merge_asof backward: ogni bar riceve l'ultima DVOL con ts <= bar. NaN -> ffill,
eventuale buco iniziale (pre-2021-03) -> bfill della prima disponibile."""
dv = pd.read_parquet(PROJECT_ROOT / "data" / "regime" / f"{asset.lower()}_dvol.parquet")
dv = dv[["timestamp", "dvol"]].sort_values("timestamp")
base = pd.DataFrame({"timestamp": df["timestamp"].values}).sort_values("timestamp")
m = pd.merge_asof(base, dv, on="timestamp", direction="backward")
s = (m["dvol"].values.astype(float)) / 100.0 # da % a frazione
s = pd.Series(s).ffill().bfill().values
return s
# --------------------------- engine con overlay opzione ---------------------------
def simulate_hedged(entries: list[dict], df: pd.DataFrame, dvol: np.ndarray | None = None,
fee_rt: float = FEE_RT, lev: float = LEV, pos: float = POS, split: int = -1,
hedge: str = "none", otm: float = 0.05, otm2: float = 0.15,
skew_mult: float = 1.10, tenor_mult: float = 1.0,
hedge_side: str = "both", min_dvol: float = 0.0) -> dict:
"""Come explore_lab.simulate ma con un overlay di opzione per-trade.
hedge:
"none" -> nessun overlay (identico a explore_lab.simulate)
"put" -> compra protezione direzionale: put se long, call se short (floor a otm)
"put_spread"-> debit spread: long protezione a otm, short a otm2 (piu' lontano) -> piu' economico, cap del payoff
"collar" -> protezione a otm finanziata vendendo l'opzione opposta OTM a otm2 (riduce/azzera il premio, cappa il guadagno)
otm: moneyness della protezione (frazione, es 0.05 = 5% OTM)
otm2: secondo strike (per put_spread / collar)
skew_mult: rincaro della sigma sull'opzione COMPRATA (conservativo)
tenor_mult: scadenza = max_bars*tenor_mult ore (>=1: copre almeno l'orizzonte)
hedge_side: "both" | "long" (solo i long-fade, le prese-coltello) | "short"
min_dvol: copri solo se DVOL all'ingresso >= soglia (frazione) -> hedge selettivo
"""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
if dvol is None:
dvol = np.full(n, 0.7)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = 0
last_exit = -1
bars_in = 0
yearly: dict[int, float] = {}
rets: list[float] = []
prem_paid = 0.0
pay_recv = 0.0
for e in entries:
i, d = e["i"], e["d"]
if i <= last_exit or i + 1 >= n or i < split:
continue
entry = c[i]
tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
base_ret = (exit_p - entry) / entry * d * lev - fee
# ---- overlay opzione ----
opt_ret = 0.0
do_hedge = hedge != "none" and (
hedge_side == "both"
or (hedge_side == "long" and d == 1)
or (hedge_side == "short" and d == -1)
) and dvol[i] >= min_dvol
if do_hedge:
T = max(mb * tenor_mult, 1.0) / HOURS_YEAR
sig = dvol[i]
sig_buy = sig * skew_mult
if d == 1: # posizione long -> rischio sotto -> PUT (floor)
Kp = entry * (1.0 - otm)
prem = bs_put(entry, Kp, T, sig_buy) / entry
payoff = max(Kp - exit_p, 0.0) / entry
if hedge == "put_spread":
Kp2 = entry * (1.0 - otm2)
prem -= bs_put(entry, Kp2, T, sig) / entry # vendo la coda piu' lontana
payoff -= max(Kp2 - exit_p, 0.0) / entry
elif hedge == "collar":
Kc = entry * (1.0 + otm2) # vendo call OTM per finanziare
prem -= bs_call(entry, Kc, T, sig) / entry
payoff -= max(exit_p - Kc, 0.0) / entry
else: # posizione short -> rischio sopra -> CALL (cap)
Kc = entry * (1.0 + otm)
prem = bs_call(entry, Kc, T, sig_buy) / entry
payoff = max(exit_p - Kc, 0.0) / entry
if hedge == "put_spread":
Kc2 = entry * (1.0 + otm2)
prem -= bs_call(entry, Kc2, T, sig) / entry
payoff -= max(exit_p - Kc2, 0.0) / entry
elif hedge == "collar":
Kp = entry * (1.0 - otm2)
prem -= bs_put(entry, Kp, T, sig) / entry
payoff -= max(Kp - exit_p, 0.0) / entry
opt_ret = lev * (payoff - prem)
prem_paid += lev * prem
pay_recv += lev * payoff
ret = base_ret + opt_ret
cb = cap
cap = max(cb + cb * pos * ret, 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; bars_in += (j - i)
last_exit = j
rets.append(ret * pos)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
return {
"trades": trades, "win": wins / trades * 100 if trades else 0.0,
"ret": (cap / 1000 - 1) * 100, "dd": max_dd * 100, "sharpe": sharpe,
"yearly": yearly, "exposure": bars_in / n * 100 if n else 0.0,
"prem_paid_pct": prem_paid * 100, "pay_recv_pct": pay_recv * 100,
}
def evaluate_hedged(name: str, entries: list[dict], df: pd.DataFrame, dvol: np.ndarray,
fees=(0.0, 0.001, 0.002), **hcfg) -> dict:
"""FULL + OOS + sweep fee per una config di overlay. Stampa una riga."""
split = int(len(df) * (1 - OOS_FRAC))
full = simulate_hedged(entries, df, dvol, **hcfg)
oos = simulate_hedged(entries, df, dvol, split=split, **hcfg)
sweep = {f: simulate_hedged(entries, df, dvol, fee_rt=f, **hcfg)["ret"] for f in fees}
sweep_oos = {f: simulate_hedged(entries, df, dvol, fee_rt=f, split=split, **hcfg)["ret"] for f in fees}
yrs = full["yearly"]; pos_yrs = sum(1 for v in yrs.values() if v > 0)
print(f" {name:<26s} trd={full['trades']:>5d} win={full['win']:>4.1f}% "
f"FULL={full['ret']:>+8.0f}% OOS={oos['ret']:>+7.0f}% DD={full['dd']:>4.0f}% "
f"oDD={oos['dd']:>4.0f}% Shrp={full['sharpe']:>4.2f} exp={full['exposure']:>4.1f}% "
f"prem={full['prem_paid_pct']:>5.0f}% pay={full['pay_recv_pct']:>5.0f}% "
f"anniPos={pos_yrs}/{len(yrs)} | fee0.2%: FULL={sweep[0.002]:>+7.0f} OOS={sweep_oos[0.002]:>+6.0f}")
return {"full": full, "oos": oos, "sweep": sweep, "sweep_oos": sweep_oos,
"pos_yrs": pos_yrs, "n_yrs": len(yrs)}
# --------------------------- baseline: Donchian fade ETH ---------------------------
def donchian_fade(df, n=20, sl_atr=2.0, max_bars=24, trend_max=3.0, ema_long=200, use_sl=True):
"""Le entries della MR02/ETH (per testarci sopra gli overlay)."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
a = atr(df, 14)
hh = pd.Series(h).rolling(n).max().shift(1).values
ll = pd.Series(l).rolling(n).min().shift(1).values
em = ema(c, ema_long)
ents = []
for i in range(max(n, ema_long, 14) + 1, len(c)):
if np.isnan(hh[i]) or np.isnan(ll[i]) or np.isnan(a[i]):
continue
if trend_max is not None and not np.isnan(em[i]) and a[i] > 0:
if abs(c[i] - em[i]) / a[i] > trend_max:
continue
tp = (hh[i] + ll[i]) / 2.0
if c[i] < ll[i] and c[i - 1] >= ll[i - 1]:
ents.append({"i": i, "d": 1, "tp": tp, "sl": (c[i] - sl_atr * a[i]) if use_sl else None, "max_bars": max_bars})
elif c[i] > hh[i] and c[i - 1] <= hh[i - 1]:
ents.append({"i": i, "d": -1, "tp": tp, "sl": (c[i] + sl_atr * a[i]) if use_sl else None, "max_bars": max_bars})
return ents
if __name__ == "__main__":
df = get_df("ETH", "1h")
dv = dvol_for(df, "ETH")
print(f"ETH 1h {len(df)} bar | DVOL media {np.nanmean(dv)*100:.0f}% (min {np.nanmin(dv)*100:.0f} max {np.nanmax(dv)*100:.0f})")
ents = donchian_fade(df) # MR02/ETH trend-filtrato, con SL ATR
ents_nosl = donchian_fade(df, use_sl=False) # senza SL (per testare l'opzione COME stop)
print("\n=== riferimento: MR02/ETH baseline vs overlay opzione (premio dedotto, conservativo) ===")
evaluate_hedged("baseline SL-ATR (no opt)", ents, df, dv, hedge="none")
evaluate_hedged("no-SL (no opt)", ents_nosl, df, dv, hedge="none")
print(" -- opzione COME floor al posto dello stop (no-SL + put/call OTM) --")
for otm in (0.03, 0.05, 0.08):
evaluate_hedged(f"no-SL +put {int(otm*100)}%OTM", ents_nosl, df, dv, hedge="put", otm=otm, hedge_side="both")
print(" -- put-spread / collar (piu' economici) sul no-SL --")
evaluate_hedged("no-SL +put_spread 5/15", ents_nosl, df, dv, hedge="put_spread", otm=0.05, otm2=0.15)
evaluate_hedged("no-SL +collar 5/10", ents_nosl, df, dv, hedge="collar", otm=0.05, otm2=0.10)
print(" -- hedge SELETTIVO: solo long-fade (prese-coltello) in alta DVOL --")
evaluate_hedged("SL +put long-only DVOL>0.8", ents, df, dv, hedge="put", otm=0.05, hedge_side="long", min_dvol=0.8)
+190
View File
@@ -0,0 +1,190 @@
"""Loader + lookup PREZZI REALI della catena opzioni (da cerbero-bite, data/options/).
Fonte: scripts/analysis/options_fetcher.py importa lo storico per-strike reale di
cerbero-bite (bid/ask/mid/IV/greche/OI/vol, BTC+ETH, dal 2026-05-01, cadenza ~12min)
in data/options/{eth,btc}_chain.parquet.
NB granularita': cerbero-bite snapshotta una FETTA ROTANTE della catena (~1 scadenza
per volta). Quindi:
- gli AGGREGATI (curva di skew, livelli di premio per moneyness/tenor) sono ROBUSTI
e sono l'uso principale -> skew_curve(), premium_levels();
- il lookup per-trade quote() e' BEST-EFFORT con una finestra di staleness
(default 48h) e ritorna il flag staleness_h: un backtest per-trade preciso e'
limitato dalla sparsita', usalo con cautela.
Uso:
from scripts.analysis.options_chain import OptionChain
oc = OptionChain("ETH")
oc.premium_levels() # tabella premi reali per moneyness x tenor
q = oc.quote(ts_ms, spot=1700, otm=0.10, opt_type="P", min_tenor_d=5, max_tenor_d=14)
# q = dict(ask_pct, iv, atm_iv, skew, spread_pct, oi, delta, strike, tenor_d, staleness_h)
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
OPT_DIR = PROJECT_ROOT / "data" / "options"
_NUM = ["strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"]
def load_chain(asset: str) -> pd.DataFrame:
df = pd.read_parquet(OPT_DIR / f"{asset.lower()}_chain.parquet")
for c in _NUM:
df[c] = pd.to_numeric(df[c], errors="coerce")
for c in ("open_interest", "volume_24h"):
df[c] = pd.to_numeric(df[c], errors="coerce")
df["ts"] = pd.to_datetime(df["timestamp"], utc=True)
df["exp"] = pd.to_datetime(df["expiry"], utc=True)
df["ts_ms"] = (df["ts"].astype("int64") // 10**6)
df["tenor_d"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
return df.sort_values("ts_ms").reset_index(drop=True)
_MKT_NUM = ["spot", "dvol", "realized_vol_30d", "iv_minus_rv", "funding_perp_annualized",
"funding_cross_annualized", "dealer_net_gamma", "gamma_flip_level",
"oi_delta_pct_4h", "macro_days_to_event"]
def load_market(asset: str | None = None) -> pd.DataFrame:
"""Pannello regime REALE pre-calcolato da cerbero-bite (market_snapshots, dal 2026-03-26,
~15min): spot, dvol, realized_vol_30d, iv_minus_rv (VRP), funding perp/cross,
dealer_net_gamma (net-GEX), gamma_flip_level, oi_delta_pct_4h, liquidation_long/short_risk.
asset=None -> tutti. Restituisce ordinato per ts con colonne ts (UTC) e ts_ms."""
df = pd.read_parquet(OPT_DIR / "market_snapshots.parquet")
if asset is not None:
df = df[df["asset"] == asset].copy()
for c in _MKT_NUM:
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, format="ISO8601")
df["ts_ms"] = df["ts"].astype("int64") // 10**6
return df.sort_values("ts_ms").reset_index(drop=True)
def attach_market(price_df: pd.DataFrame, asset: str, cols: list[str] | None = None) -> pd.DataFrame:
"""merge_asof CAUSALE: ogni barra di price_df (serve colonna 'timestamp' in ms) riceve
l'ultimo market_snapshot con ts <= barra. Pannello pronto per regime_lab/ricerca:
spot, dvol, realized_vol_30d, iv_minus_rv (VRP), funding perp/cross, dealer_net_gamma
(net-GEX), gamma_flip_level, oi_delta_pct_4h, liquidation_long/short_risk.
Ritorna una copia con le colonne pannello (NaN dove non c'e' ancora storia: dal 2026-03-26)."""
m = load_market(asset)
cols = cols or (_MKT_NUM + ["liquidation_long_risk", "liquidation_short_risk"])
keep = [c for c in cols if c in m.columns]
base = price_df.copy()
# chiave di merge = datetime tz-aware (robusto a timestamp int-ms o datetime; NIENTE astype int64
# su datetime -> darebbe nanosecondi e matcherebbe tutto all'ultimo snapshot = LOOK-AHEAD).
if np.issubdtype(base["timestamp"].dtype, np.integer):
base["_k"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
else:
base["_k"] = pd.to_datetime(base["timestamp"], utc=True)
base["_k"] = base["_k"].astype("datetime64[ns, UTC]")
mk = m[["ts"] + keep].rename(columns={"ts": "_k"})
mk["_k"] = mk["_k"].astype("datetime64[ns, UTC]")
out = pd.merge_asof(base.sort_values("_k"), mk.sort_values("_k"),
on="_k", direction="backward")
return out.drop(columns="_k")
class OptionChain:
def __init__(self, asset: str):
self.asset = asset
self.df = load_chain(asset)
self._ts = self.df["ts_ms"].values
# ---------------- aggregati robusti ----------------
def _spot_proxy(self) -> pd.Series:
"""spot ~ strike del put con delta piu' vicino a -0.5, per snapshot."""
puts = self.df[self.df["option_type"] == "P"].copy()
puts["d_atm"] = (puts["delta"] + 0.5).abs()
atm = puts.sort_values("d_atm").groupby("timestamp").first()
return atm["strike"]
def skew_curve(self, opt_type: str = "P") -> pd.DataFrame:
"""IV mediana / IV-ATM per banda di moneyness (strike/spot)."""
spot = self._spot_proxy()
d = self.df[self.df["option_type"] == opt_type].copy()
d["spot"] = d["timestamp"].map(spot)
d = d.dropna(subset=["spot", "iv"])
d["m"] = d["strike"] / d["spot"]
atm_iv = d.assign(da=(d["delta"].abs() - 0.5).abs()).sort_values("da").groupby("timestamp")["iv"].first()
d["atm_iv"] = d["timestamp"].map(atm_iv)
d["skew"] = d["iv"] / d["atm_iv"]
bins = [0.7, 0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15, 1.3]
d["band"] = pd.cut(d["m"], bins)
g = d.groupby("band", observed=True).agg(
n=("skew", "size"), iv_med=("iv", "median"), skew_med=("skew", "median"),
spread_med=("ask", lambda s: float("nan")))
# spread% mediano
d["spread_pct"] = (d["ask"] - d["bid"]) / ((d["ask"] + d["bid"]) / 2) * 100
g["spread_med%"] = d.groupby("band", observed=True)["spread_pct"].median()
g["oi_med"] = d.groupby("band", observed=True)["open_interest"].median()
return g.drop(columns=["spread_med"])
def premium_levels(self, opt_type: str = "P") -> pd.DataFrame:
"""premio reale mediano (ask, %spot) per banda moneyness x tenor."""
spot = self._spot_proxy()
d = self.df[self.df["option_type"] == opt_type].copy()
d["spot"] = d["timestamp"].map(spot)
d = d.dropna(subset=["spot", "ask"])
d["m"] = d["strike"] / d["spot"]
d["ask_pct"] = d["ask"] * 100.0 # ask quotato in coin -> %spot = ask*100
d["tenor_b"] = pd.cut(d["tenor_d"], [0, 3.5, 14, 45, 400],
labels=["1-3d", "4-14d", "15-45d", ">45d"])
d["m_b"] = pd.cut(d["m"], [0.7, 0.85, 0.9, 0.95, 1.05, 1.15, 1.3],
labels=["<-10%", "-10%", "-7%", "ATM", "+7%", "+10%"])
g = d.groupby(["m_b", "tenor_b"], observed=True).agg(
n=("ask_pct", "size"), prem_pct=("ask_pct", "median"),
iv=("iv", "median"), oi=("open_interest", "median"))
return g
# ---------------- lookup per-trade (best effort) ----------------
def quote(self, ts_ms: int, spot: float, otm: float = 0.10, opt_type: str = "P",
min_tenor_d: float = 5.0, max_tenor_d: float = 14.0,
max_staleness_h: float = 48.0) -> dict | None:
"""Quote REALE piu' fresca <= ts_ms per la protezione a `otm` OTM e tenor nel range.
put: strike target = spot*(1-otm); call: spot*(1+otm). None se nulla nella finestra."""
lo = ts_ms - int(max_staleness_h * 3600 * 1000)
i1 = np.searchsorted(self._ts, ts_ms, "right")
i0 = np.searchsorted(self._ts, lo, "left")
if i1 <= i0:
return None
w = self.df.iloc[i0:i1]
w = w[w["option_type"] == opt_type]
w = w[(w["tenor_d"] >= min_tenor_d) & (w["tenor_d"] <= max_tenor_d)]
if w.empty:
return None
# ultima quota per strumento, poi strike piu' vicino al target
w = w.sort_values("ts_ms").groupby("instrument_name").tail(1)
target = spot * (1 - otm) if opt_type == "P" else spot * (1 + otm)
row = w.iloc[(w["strike"] - target).abs().argmin()]
atm = w.iloc[(w["delta"].abs() - 0.5).abs().argmin()]
ask, bid = float(row["ask"]), float(row["bid"])
return dict(
ask_pct=ask * 100.0, iv=float(row["iv"]), atm_iv=float(atm["iv"]),
skew=float(row["iv"]) / float(atm["iv"]) if atm["iv"] else float("nan"),
spread_pct=(ask - bid) / ((ask + bid) / 2) * 100 if (ask + bid) > 0 else float("nan"),
oi=int(row["open_interest"] or 0), delta=float(row["delta"]),
strike=float(row["strike"]), tenor_d=float(row["tenor_d"]),
staleness_h=(ts_ms - int(row["ts_ms"])) / 3600000.0,
instrument=row["instrument_name"])
if __name__ == "__main__":
for asset in ("ETH", "BTC"):
oc = OptionChain(asset)
print(f"\n===== {asset}{len(oc.df)} righe, {oc.df['ts'].min().date()} -> {oc.df['ts'].max().date()} =====")
print("\n--- curva di skew (put, IV mediana / IV-ATM) ---")
print(oc.skew_curve("P").to_string())
print("\n--- premi reali mediani (ask %spot) per moneyness x tenor ---")
print(oc.premium_levels("P").to_string())
# quote demo: ultimo ts disponibile, 10% OTM put settimanale
ts = int(oc.df["ts_ms"].iloc[-1]); sp = float(oc._spot_proxy().iloc[-1])
q = oc.quote(ts, spot=sp, otm=0.10, opt_type="P", min_tenor_d=5, max_tenor_d=14)
print(f"\n--- quote demo: put 10%OTM settimanale ~spot {sp:.0f} ---\n {q}")
+83
View File
@@ -0,0 +1,83 @@
"""Importa lo storico per-strike delle opzioni da cerbero-bite -> data/options/.
cerbero-bite (container Docker accanto) accumula in continuo gli snapshot della
catena opzioni Deribit (BTC+ETH) nella tabella `option_chain_snapshots` del suo
SQLite (`/app/data/state.sqlite`, volume `cerbero-bite_bite-data`, root-only).
Questo e' lo storico per-strike REALE (bid/ask/mid/IV/greche/OI/volume) che il
progetto credeva non-backtestabile (muro ARGO/GEX): qui esiste ed e' gratis.
Il volume non e' leggibile direttamente (root), quindi esportiamo via `docker
exec` (il container ha pandas+pyarrow) e copiamo i parquet in data/options/.
Analogo a regime_fetcher.py -> data/regime/. Rigenera con:
uv run python scripts/analysis/options_fetcher.py
NB: snapshot da 2026-05-01 in poi (cerbero-bite e' partito allora), cadenza ~12min.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
OUT = PROJECT_ROOT / "data" / "options"
BITE_COMPOSE = "/opt/docker/cerbero-bite/docker-compose.yml"
BITE_SERVICE = "cerbero-bite"
_EXPORT = r'''
import sqlite3, pandas as pd
con = sqlite3.connect("file:/app/data/state.sqlite?mode=ro", uri=True)
for asset in ("ETH", "BTC"):
df = pd.read_sql_query(
"SELECT * FROM option_chain_snapshots WHERE asset=?", con, params=(asset,))
df.to_parquet(f"/tmp/{asset.lower()}_chain.parquet")
print(f"{asset} {len(df)}")
try:
dv = pd.read_sql_query("SELECT * FROM dvol_history", con)
dv.to_parquet("/tmp/dvol_history.parquet"); print(f"DVOL {len(dv)}")
except Exception as e:
print("dvol skip", e)
# market_snapshots: pannello regime allineato (spot, dvol, VRP, funding, GEX dealer,
# gamma_flip, liquidation risk, oi_delta) -- feature reali pre-calcolate.
try:
ms = pd.read_sql_query("SELECT * FROM market_snapshots", con)
ms.to_parquet("/tmp/market_snapshots.parquet"); print(f"MARKET {len(ms)}")
except Exception as e:
print("market skip", e)
'''
def _container_id() -> str:
out = subprocess.run(["docker", "compose", "-f", BITE_COMPOSE, "ps", "-q", BITE_SERVICE],
capture_output=True, text=True, check=True)
cid = out.stdout.strip().splitlines()[0]
if not cid:
raise RuntimeError("container cerbero-bite non trovato (e' su?)")
return cid
def fetch() -> None:
OUT.mkdir(parents=True, exist_ok=True)
print("export dalla SQLite di cerbero-bite (docker exec)...")
r = subprocess.run(["docker", "compose", "-f", BITE_COMPOSE, "exec", "-T", BITE_SERVICE,
"python", "-c", _EXPORT], capture_output=True, text=True)
print(r.stdout.strip())
if r.returncode != 0:
print(r.stderr[-2000:], file=sys.stderr)
raise SystemExit("export fallito")
cid = _container_id()
for fn in ("eth_chain.parquet", "btc_chain.parquet", "dvol_history.parquet",
"market_snapshots.parquet"):
dst = OUT / fn
cp = subprocess.run(["docker", "cp", f"{cid}:/tmp/{fn}", str(dst)],
capture_output=True, text=True)
if cp.returncode == 0:
print(f" -> {dst.relative_to(PROJECT_ROOT)} ({dst.stat().st_size // 1024} KB)")
else:
print(f" (skip {fn}: {cp.stderr.strip()})")
print("OK. Loader: scripts/analysis/options_chain.py")
if __name__ == "__main__":
fetch()
+203
View File
@@ -0,0 +1,203 @@
"""Check candele FLAT (O=H=L=C, liquidita' zero) sui pairs ETH/BTC a 15m.
Rischio noto (CLAUDE.md): ETH 15m ha 14-30%/anno di candele flat per bassa liquidita'
del perpetuo. Su un pairs, un close stale gonfia lo z-score (l'altra gamba si muove,
questa e' ferma) -> segnale di "reversione" FINTO che rientra solo quando la gamba
stale si sblocca: profitto NON eseguibile dal vivo. Questo gonfierebbe il backtest 15m.
Test:
[1] prevalenza candele flat per anno (ETH 15m, BTC 15m).
[2] quanti trade del pairs 15m hanno ENTRY/EXIT su una candela flat (gamba stale).
[3] re-sim flat-aware: entry/exit SOLO su barre pulite (non-flat in ENTRAMBE le gambe)
-> quanto sopravvive l'edge? (parita': senza flat-skip == pairs_sim).
[4] gate PORT06 col 15m flat-filtrato vs baseline 1h.
uv run python scripts/analysis/pairs15m_flatcheck.py
"""
from __future__ import annotations
import sys
from pathlib import Path
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.analysis.pairs_research import pairs_sim, OOS_FRAC, FEE_RT, LEV, POS, BARS_YEAR
from scripts.analysis.report_families import daily_from
from scripts.analysis.combine_portfolio import metrics, SPLIT, OOS_DATE
from scripts.analysis.pairs15m_port06_gate import port_metrics, eth_btc_daily, UNIV_1H, GAME_15M
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
def aligned2(a, b, tf="15m"):
"""Merge con OHLC di ENTRAMBE le gambe (serve per rilevare i flat su entrambe)."""
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_a" if x != "timestamp" else x)
db = load_data(b, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_b" if x != "timestamp" else x)
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
return m
def is_flat(o, h, l, c):
return (o == h) & (h == l) & (l == c)
def flat_prevalence(asset, tf="15m"):
d = load_data(asset, tf)
d = d.copy()
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
fl = is_flat(d["open"].values, d["high"].values, d["low"].values, d["close"].values)
d["flat"] = fl
by = d.groupby(d["dt"].dt.year)["flat"].mean() * 100
return by, fl.mean() * 100
def pairs_sim_flataware(a, b, tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35,
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS,
split_frac=0.0, skip_flat=True):
"""Come pairs_sim ma: entry/exit consentiti SOLO su barre pulite (se skip_flat).
Ritorna anche n_entry_flat / n_exit_flat (diagnostica, calcolata sempre)."""
m = aligned2(a, b, tf)
ca, cb = m["close_a"].values, m["close_b"].values
flat_a = is_flat(m["open_a"].values, m["high_a"].values, m["low_a"].values, ca)
flat_b = is_flat(m["open_b"].values, m["high_b"].values, m["low_b"].values, cb)
flat = flat_a | flat_b # barra "sporca" se una delle due gambe e' flat
r = np.log(ca / cb)
dr = np.abs(np.diff(r, prepend=r[0]))
ma = pd.Series(r).rolling(n).mean().values
sd = pd.Series(r).rolling(n).std().values
z = (r - ma) / np.where(sd == 0, np.nan, sd)
ts = m["dt"]; N = len(r)
split = int(N * split_frac)
fee = 2 * fee_rt * lev
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = 0; rets = []; yearly = {}
eq_ts, eq_v = [], []
n_entry_flat = n_exit_flat = 0
for i in range(n + 1, N - 1):
if i < split or np.isnan(z[i]) or dr[i] > jump_max or i <= last:
continue
if z[i] <= -z_in:
d = 1
elif z[i] >= z_in:
d = -1
else:
continue
if flat[i]:
n_entry_flat += 1
if skip_flat:
continue # non si entra su una gamba stale
# exit: |z|<=z_exit o max_bars; se skip_flat, salta le barre flat come uscita
j = min(i + max_bars, N - 1)
for k in range(1, max_bars + 1):
jj = i + k
if jj >= N:
j = N - 1; break
if skip_flat and flat[jj]:
j = jj # avanza, non esce su barra stale
continue
if abs(z[jj]) <= z_exit:
j = jj; break
j = jj
if flat[j]:
n_exit_flat += 1
if skip_flat:
# spingi all'ultima barra pulita entro l'orizzonte
back = j
while back > i and flat[back]:
back -= 1
j = back if back > i else j
retA = (ca[j] - ca[i]) / ca[i]
retB = (cb[j] - cb[i]) / cb[i]
ret = (retA - retB) * d * lev - fee
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
sharpe = 0.0
if len(rets) > 1 and np.std(rets) > 0:
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades / yrs_span))
ret_tot = (cap / 1000 - 1) * 100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot,
dd=dd * 100, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v,
n_entry_flat=n_entry_flat, n_exit_flat=n_exit_flat)
def main():
print("=" * 100)
print(" CHECK FLAT-CANDLE — ETH/BTC pairs 15m (gate condizionato)")
print("=" * 100)
# [1] prevalenza
print("\n[1] Prevalenza candele flat (O=H=L=C) per anno, 15m:")
for asset in ("ETH", "BTC"):
by, tot = flat_prevalence(asset, "15m")
print(f" {asset}: media {tot:.1f}% | " +
" ".join(f"{y}:{v:.0f}%" for y, v in by.items()))
# [2] quanti trade toccano un flat (sim SENZA skip per diagnostica)
diag = pairs_sim_flataware("ETH", "BTC", **GAME_15M, skip_flat=False)
tr = diag["trades"]
print(f"\n[2] Trade 15m totali: {tr} | entry su barra flat: {diag['n_entry_flat']} "
f"({diag['n_entry_flat']/tr*100:.1f}%) | exit su barra flat: {diag['n_exit_flat']} "
f"({diag['n_exit_flat']/tr*100:.1f}%)")
# [3] parita' + edge filtrato
print("\n[3] Edge 15m: NO-skip (== pairs_sim) vs FLAT-AWARE (entry/exit solo barre pulite):")
# parita': flataware skip_flat=False deve ~== pairs_sim
base_ps = pairs_sim("ETH", "BTC", **GAME_15M, pos=POS, lev=LEV)
print(f" parita' pairs_sim : trd {base_ps['trades']:>5d} Sh {base_ps['sharpe']:.2f} "
f"DD {base_ps['dd']:.0f}% ret {base_ps['ret']:+.0f}%")
print(f" flataware (no-skip) : trd {diag['trades']:>5d} Sh {diag['sharpe']:.2f} "
f"DD {diag['dd']:.0f}% ret {diag['ret']:+.0f}%")
filt = pairs_sim_flataware("ETH", "BTC", **GAME_15M, skip_flat=True)
filt_o = pairs_sim_flataware("ETH", "BTC", **GAME_15M, skip_flat=True, split_frac=1 - OOS_FRAC)
print(f" FLAT-AWARE (skip) : trd {filt['trades']:>5d} Sh {filt['sharpe']:.2f} "
f"DD {filt['dd']:.0f}% ret {filt['ret']:+.0f}% | OOS Sh {filt_o['sharpe']:.2f} DD {filt_o['dd']:.0f}%")
drop = (1 - filt['trades'] / diag['trades']) * 100
sh_keep = filt['sharpe'] / diag['sharpe'] * 100 if diag['sharpe'] else 0
verdict = "EDGE NON artefatto flat" if sh_keep > 70 else "EDGE in larga parte ARTEFATTO flat"
print(f" -> rimossi {drop:.1f}% dei trade; Sharpe trattenuto {sh_keep:.0f}% ({verdict})")
# [4] gate PORT06 col 15m flat-filtrato
print("\n[4] GATE PORT06 — ETH/BTC: baseline 1h vs SWAP 15m-FLATAWARE vs BLEND:")
p = PORTFOLIOS["PORT06"]
pair_ids = [s.sid for s in p.sleeves if s.sid.startswith("PR_")]
eq_base = dict(all_sleeve_equities())
e1h, _ = eth_btc_daily(UNIV_1H)
e15f = daily_from(filt["eq_ts"], filt["eq_v"])
# blend 1h + 15m-flataware (50/50 daily-rebalanced)
from scripts.analysis.pairs15m_port06_gate import blend
eblend = blend(e1h, e15f, 0.5)
corr = e1h.pct_change().fillna(0).corr(e15f.pct_change().fillna(0))
print(f" corr 1h vs 15m-flataware: {corr:.3f}")
print(f" {'variante':<18s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR':>6s} | {'OOS Sh':>7s}{'OOS DD%':>8s}")
print(" " + "-" * 70)
res = {}
for tag, eth in [("baseline 1h", e1h), ("SWAP 15m-flat", e15f), ("BLEND 1h+15m-flat", eblend)]:
members = dict(eq_base); members["PR_ETHBTC"] = eth
f, o = port_metrics(members, p)
res[tag] = (f, o)
print(f" {tag:<18s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>5.0f}%"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f}")
fb, ob = res["baseline 1h"]
print("\n VERDETTO (vs baseline 1h, fee backtest): Sharpe non peggiora E DD <= baseline")
for tag in ("SWAP 15m-flat", "BLEND 1h+15m-flat"):
f, o = res[tag]
ok = o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9 and f["sharpe"] >= fb["sharpe"] - 0.02 and f["dd"] <= fb["dd"] + 1e-9
print(f" {tag:<18s}: OOS {ob['sharpe']:.2f}->{o['sharpe']:.2f} DD {ob['dd']:.2f}->{o['dd']:.2f}"
f" | FULL {fb['sharpe']:.2f}->{f['sharpe']:.2f} DD {fb['dd']:.2f}->{f['dd']:.2f} => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,62 @@
"""GATE PORT06 FINALE — ETH/BTC 15m flat-skip, engine canonico pairs_sim_flat.
Usa pairs_sim_flat(flat_skip=True), cioe' la STESSA semantica live-realizable del
PairsWorker (uscita alla prima barra pulita), validata da validate_worker_pairs.
Conferma i numeri deployabili: baseline 1h vs SWAP 15m vs BLEND 1h+15m.
uv run python scripts/analysis/pairs15m_gate_final.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.pairs_research import pairs_sim_flat
from scripts.analysis.report_families import daily_from
from scripts.analysis.pairs15m_port06_gate import (port_metrics, eth_btc_daily, blend,
UNIV_1H, POS, LEV)
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
CFG_15M = dict(n=66, z_in=1.674, z_exit=1.0, max_bars=35)
def main():
p = PORTFOLIOS["PORT06"]
eq_base = dict(all_sleeve_equities())
e1h, _ = eth_btc_daily(UNIV_1H)
r15 = pairs_sim_flat("ETH", "BTC", tf="15m", **CFG_15M, flat_skip=True, pos=POS, lev=LEV)
e15 = daily_from(r15["eq_ts"], r15["eq_v"])
eblend = blend(e1h, e15, 0.5)
corr = e1h.pct_change().fillna(0).corr(e15.pct_change().fillna(0))
print("=" * 92)
print(" GATE PORT06 FINALE — ETH/BTC 15m flat-skip (pairs_sim_flat == worker live)")
print(f" 15m: {r15['trades']} trade, {r15['n_skip_entry']} ingressi flat saltati | "
f"corr 1h vs 15m = {corr:.3f}")
print("=" * 92)
print(f" {'variante':<18s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR':>6s} | {'OOS Sh':>7s}{'OOS DD%':>8s}")
print(" " + "-" * 70)
res = {}
for tag, eth in [("baseline 1h", e1h), ("SWAP 15m-flat", e15), ("BLEND 1h+15m", eblend)]:
members = dict(eq_base); members["PR_ETHBTC"] = eth
f, o = port_metrics(members, p)
res[tag] = (f, o)
print(f" {tag:<18s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>5.0f}%"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f}")
fb, ob = res["baseline 1h"]
print("\n Promosso se OOS Sharpe non peggiora E DD<=baseline (PORT06):")
for tag in ("SWAP 15m-flat", "BLEND 1h+15m"):
f, o = res[tag]
ok = o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9 \
and f["sharpe"] >= fb["sharpe"] - 0.02 and f["dd"] <= fb["dd"] + 1e-9
print(f" {tag:<18s}: OOS {ob['sharpe']:.2f}->{o['sharpe']:.2f} DD {ob['dd']:.2f}->{o['dd']:.2f}"
f" | FULL {fb['sharpe']:.2f}->{f['sharpe']:.2f} DD {fb['dd']:.2f}->{f['dd']:.2f}"
f" => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,81 @@
"""Smoke LIVE del nuovo percorso 15m: fetch DIRETTO 15m da Cerbero per ETH/BTC +
freschezza + flat-fraction + un tick reale del PairsWorker(flat_skip).
Verifica cio' che il backtest non vede: che Cerbero serva candele 15m fresche per
entrambe le gambe (il runner ora le fetcha dirette, non resamplate dal 1h) e che il
worker 15m le processi senza errori. NON apre ordini reali (l'esecuzione a 2 gambe e'
gia' coperta da live_pairs_smoke.py, indipendente dal timeframe).
uv run python scripts/analysis/pairs15m_live_smoke.py
"""
from __future__ import annotations
import sys
import shutil
import tempfile
from datetime import datetime, timezone, timedelta
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
from src.live.pairs_worker import PairsWorker
CFG = {"n": 66, "z_in": 1.674, "z_exit": 1.0, "max_bars": 35, "flat_skip": True}
def fetch15(cli, asset, days=14):
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
candles = cli.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), "15m")
if not candles:
return inst, None
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
return inst, df.sort_values("timestamp").reset_index(drop=True)
def main():
print("=" * 84)
print(" SMOKE LIVE — ETH/BTC pairs 15m (fetch diretto Cerbero + tick worker flat-skip)")
print("=" * 84)
cli = CerberoClient()
inst_a, da = fetch15(cli, "ETH")
inst_b, db = fetch15(cli, "BTC")
ok = True
for asset, inst, df in [("ETH", inst_a, da), ("BTC", inst_b, db)]:
if df is None or df.empty:
print(f" {asset} ({inst}): NESSUNA candela 15m -> FAIL"); ok = False; continue
last = pd.to_datetime(df["timestamp"].iloc[-1], unit="ms", utc=True)
age_min = (datetime.now(timezone.utc) - last).total_seconds() / 60
flat = ((df["open"] == df["high"]) & (df["high"] == df["low"]) &
(df["low"] == df["close"])).mean() * 100
fresh = age_min < 60
print(f" {asset} ({inst}): {len(df)} barre 15m | ultima {last:%Y-%m-%d %H:%M} "
f"({age_min:.0f} min fa, {'FRESCO' if fresh else 'STALE'}) | flat {flat:.1f}%")
ok &= fresh
if da is None or db is None:
print("\n ESITO: FAIL (feed 15m assente)."); return
# tick reale del worker 15m
tmp = Path(tempfile.mkdtemp(prefix="smoke15m_"))
try:
w = PairsWorker("ETH", "BTC", "15m", params=CFG, fee_rt=0.001, data_dir=tmp)
df_a = pd.DataFrame({"timestamp": da["timestamp"], "open": da["open"], "high": da["high"],
"low": da["low"], "close": da["close"]})
df_b = pd.DataFrame({"timestamp": db["timestamp"], "open": db["open"], "high": db["high"],
"low": db["low"], "close": db["close"]})
w.tick(df_a, df_b)
print(f"\n Worker 15m flat_skip={w.flat_skip} -> tick OK | {w.status_summary}")
print(f" ESITO: {'OK — feed 15m fresco e worker ticca' if ok else 'ATTENZIONE: feed 15m stale/parziale'}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,169 @@
"""GATE PORT06 — ETH/BTC pairs a 15m (origine: gioco "Blind Traders", vincitore #43).
Domanda onesta sollevata dal gioco: la coppia ETH/BTC (gia' deployata in PR01 a 1h,
config UNIV n=50 z_in=2.0 z_exit=0.75 max_bars=72) MIGLIORA se girata a 15m con la
config trovata dal gioco (n=66 z_in=1.67 z_exit=1.0 max_bars=35), oppure e' solo una
variante piu' veloce, correlata, dello STESSO spread?
Metodo (engine di PRODUZIONE pairs_sim, NON il motore-giocattolo del gioco):
[1] PARITA': pairs_sim ETH/BTC 1h UNIV (pos0.15 lev3) == sleeve canonico PR_ETHBTC.
[2] CORRELAZIONE 1h vs 15m (rendimenti giornalieri): se ~1 e' ridondante.
[3] STANDALONE 1h vs 15m (+ griglia robustezza n x z_in su 15m, + stress fee 2x).
[4] GATE PORT06: baseline(1h) vs SWAP(15m) vs BLEND(0.5*1h+0.5*15m) per la sleeve
ETH/BTC; promosso se vs baseline l'OOS Sharpe non peggiora E il DD scende
(PORT06 e famiglia), come gli altri gate del progetto.
uv run python scripts/analysis/pairs15m_port06_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE, IDX
from scripts.analysis.pairs_research import pairs_sim, OOS_FRAC
from scripts.analysis.report_families import daily_from
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
POS, LEV = 0.15, 3.0 # config CANONICA (== build_everything)
UNIV_1H = dict(tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72)
GAME_15M = dict(tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35) # vincitore gioco
def eth_btc_daily(cfg):
r = pairs_sim("ETH", "BTC", **{**cfg, "pos": POS, "lev": LEV})
return daily_from(r["eq_ts"], r["eq_v"]), r
def std_metrics(cfg, fee_rt=0.001):
f = pairs_sim("ETH", "BTC", **{**cfg, "pos": POS, "lev": LEV, "fee_rt": fee_rt})
o = pairs_sim("ETH", "BTC", **{**cfg, "pos": POS, "lev": LEV, "fee_rt": fee_rt,
"split_frac": 1 - OOS_FRAC})
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
return f, o, pos_y, len(yrs)
def port_metrics(members, p):
ids = p.sleeve_ids
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
def fam_metrics(eqs):
dr = port_returns(eqs)
return metrics(dr), metrics(dr, lo=SPLIT)
def blend(e1, e2, w1=0.5):
"""Sleeve combinata: media pesata dei rendimenti giornalieri (ribilancio 1D)."""
r1 = e1.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
r2 = e2.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
rb = w1 * r1 + (1 - w1) * r2
eq = (1 + rb).cumprod()
return eq / eq.iloc[0]
def main():
p = PORTFOLIOS["PORT06"]
pair_ids = [s.sid for s in p.sleeves if s.sid.startswith("PR_")]
print("=" * 100)
print(" GATE PORT06 — ETH/BTC pairs 15m (vincitore gioco) vs 1h deployato")
print(f" pos={POS} lev={LEV} (canonico) | OOS da {OOS_DATE} | coppie PORT06: {pair_ids}")
print("=" * 100)
from src.portfolio.sleeves import all_sleeve_equities
eq_base = dict(all_sleeve_equities())
# [1] PARITA'
print("\n[1] PARITA' pairs_sim ETH/BTC 1h UNIV (pos0.15 lev3) == sleeve canonico PR_ETHBTC:")
e1h, r1h = eth_btc_daily(UNIV_1H)
base = eq_base["PR_ETHBTC"]
corr = base.pct_change().fillna(0).corr(e1h.pct_change().fillna(0))
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
rr = (e1h.iloc[-1] / e1h.iloc[0] - 1) * 100
par_ok = corr > 0.999 and abs(rr - rb) <= max(1.0, abs(rb) * 0.01)
print(f" corr={corr:.5f} ret canon {rb:+.0f}% vs replay {rr:+.0f}% "
f"{'OK' if par_ok else '<-- MISMATCH (STOP)'}")
if not par_ok:
return
# [2] CORRELAZIONE 1h vs 15m
e15, r15 = eth_btc_daily(GAME_15M)
c = e1h.pct_change().fillna(0).corr(e15.pct_change().fillna(0))
print(f"\n[2] CORRELAZIONE rendimenti giornalieri ETH/BTC 1h vs 15m: {c:.3f}")
print(f" {'(quasi-duplicato se >0.8; diversificatore se <0.5)':<60s}")
# [3] STANDALONE 1h vs 15m
print("\n[3] STANDALONE ETH/BTC (netto fee 0.20% RT/coppia, leva 3x):")
print(f" {'cfg':<10s}{'trd':>6s}{'win%':>6s}{'FULL%':>9s}{'OOS%':>9s}{'CAGR%':>7s}"
f"{'DD%':>6s}{'oDD%':>7s}{'Shrp':>6s}{'anni+':>7s}{'fee2x FULL%':>12s}")
for tag, cfg in [("1h UNIV", UNIV_1H), ("15m gioco", GAME_15M)]:
f, o, py, ny = std_metrics(cfg)
f2, _, _, _ = std_metrics(cfg, fee_rt=0.002)
print(f" {tag:<10s}{f['trades']:>6d}{f['win']:>6.1f}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['cagr']:>7.0f}{f['dd']:>6.0f}{o['dd']:>7.0f}{f['sharpe']:>6.2f}"
f"{f'{py}/{ny}':>7s}{f2['ret']:>+12.0f}")
# robustezza: plateau n x z_in su 15m (Sharpe>1?)
print("\n Robustezza 15m (Sharpe full, griglia n x z_in, z_exit=1.0 max_bars=35):")
ns = [40, 50, 66, 80]; zs = [1.5, 1.7, 2.0, 2.5]
cells = 0; tot = 0
hdr = " n\\z_in " + "".join(f"{z:>7.1f}" for z in zs)
print(hdr)
for n in ns:
row = f" {n:>6d} "
for z in zs:
s = pairs_sim("ETH", "BTC", tf="15m", n=n, z_in=z, z_exit=1.0,
max_bars=35, pos=POS, lev=LEV)["sharpe"]
tot += 1; cells += s > 1
row += f"{s:>7.2f}"
print(row)
print(f" -> {cells}/{tot} celle Sharpe>1 (plateau se ~tutte; picco se poche)")
# [4] GATE PORT06
print("\n[4] GATE PORT06 — sleeve ETH/BTC: baseline(1h) vs SWAP(15m) vs BLEND(50/50):")
variants = {
"baseline 1h": e1h,
"SWAP 15m": e15,
"BLEND 1h+15m": blend(e1h, e15, 0.5),
}
print(f" {'variante':<14s} | {'FULL Sh':>8s}{'FULL DD%':>9s}{'CAGR':>6s}"
f" | {'OOS Sh':>7s}{'OOS DD%':>8s} | {'famSh':>6s}{'famDD%':>7s}")
print(" " + "-" * 78)
res = {}
for tag, eth in variants.items():
members = dict(eq_base)
members["PR_ETHBTC"] = eth
f, o = port_metrics(members, p)
fam_eqs = {sid: (eth if sid == "PR_ETHBTC" else eq_base[sid]) for sid in pair_ids}
ff, _ = fam_metrics(fam_eqs)
res[tag] = (f, o, ff)
print(f" {tag:<14s} | {f['sharpe']:>8.2f}{f['dd']:>9.2f}{f['cagr']:>5.0f}%"
f" | {o['sharpe']:>7.2f}{o['dd']:>8.2f} | {ff['sharpe']:>6.2f}{ff['dd']:>7.1f}")
# VERDETTO
fb, ob, _ = res["baseline 1h"]
print("\n" + "=" * 100)
print(" VERDETTO vs baseline 1h: promosso se OOS Sharpe non peggiora E DD scende (PORT06 e famiglia)")
print("=" * 100)
for tag in ("SWAP 15m", "BLEND 1h+15m"):
f, o, ff = res[tag]
ok = (o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9
and f["sharpe"] >= fb["sharpe"] - 0.02)
print(f" {tag:<14s}: OOS Sh {ob['sharpe']:.2f}->{o['sharpe']:.2f} "
f"DD {ob['dd']:.2f}->{o['dd']:.2f} | FULL Sh {fb['sharpe']:.2f}->{f['sharpe']:.2f} "
f"DD {fb['dd']:.2f}->{f['dd']:.2f} => {'PROMOSSO' if ok else 'bocciato'}")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
"""GATE: aggiungere ETH/BTC 30m (vincitore gioco sessione 2) AL BLEND attuale (1h+15m)?
Domanda: il 30m e' un 3o timeframe utile dello spread ETH/BTC, o e' ridondante col 15m
adiacente gia' deployato? Test (engine pairs_sim_flat, == worker):
[1] correlazioni: 30m vs 1h, 30m vs 15m (se ~1 col 15m -> ridondante).
[2] gate PORT06: baseline ATTUALE (6 pairs, incl 15m) vs +30m (7 pairs), mezza size.
[3] robustezza ai costi (fee sweep) del 30m.
uv run python scripts/analysis/pairs30m_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
from scripts.analysis.report_families import daily_from
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
WIN_30M = dict(n=53, z_in=1.947, z_exit=1.0, max_bars=24) # vincitore gioco sess.2
def daily(cfg, tf, flat_skip, pos=0.15):
if flat_skip:
r = pairs_sim_flat("ETH", "BTC", tf=tf, **cfg, flat_skip=True, pos=pos)
else:
r = pairs_sim("ETH", "BTC", tf=tf, **cfg, pos=pos)
return daily_from(r["eq_ts"], r["eq_v"]), r
def port_metrics(members, ids, clusters, caps):
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector("cap", ids, dr, caps=caps, clusters=clusters)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT), w
def main():
p = PORTFOLIOS["PORT06"]
eq_base = dict(all_sleeve_equities()) # include gia' PR_ETHBTC (1h) e PR_ETHBTC_15M
e1h = eq_base["PR_ETHBTC"]
e15 = eq_base["PR_ETHBTC_15M"]
e30h, r30 = daily(WIN_30M, "30m", flat_skip=True, pos=0.075) # half size come il 15m
print("=" * 92)
print(" GATE — ETH/BTC 30m (vincitore gioco sess.2) sopra il BLEND 1h+15m attuale")
print(f" 30m: {r30['trades']} trade, {r30.get('n_skip_entry',0)} ingressi flat saltati")
print("=" * 92)
def corr(a, b): return a.pct_change().fillna(0).corr(b.pct_change().fillna(0))
print("\n[1] CORRELAZIONI (rendimenti giornalieri):")
print(f" 30m vs 1h : {corr(e30h, e1h):.3f}")
print(f" 30m vs 15m: {corr(e30h, e15):.3f} <-- se alta, ridondante col 15m gia' deployato")
print(f" (rif) 15m vs 1h: {corr(e15, e1h):.3f}")
print(f"\n[2] GATE PORT06 (cap PAIRS 0.33 + SHAPE 0.0588) | OOS da {OOS_DATE}:")
ids0 = list(p.sleeve_ids)
cl0 = p.clusters
caps = p.caps
f0, o0, _ = port_metrics(eq_base, ids0, cl0, caps)
# + 30m
mem1 = dict(eq_base); mem1["PR_ETHBTC_30M"] = e30h
ids1 = ids0 + ["PR_ETHBTC_30M"]
cl1 = dict(cl0); cl1["PR_ETHBTC_30M"] = "ETH-rev"
f1, o1, w1 = port_metrics(mem1, ids1, cl1, caps)
print(f" {'config':<22}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
print(f" {'ATTUALE (1h+15m)':<22}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
print(f" {'+30m (1h+15m+30m)':<22}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}")
ok = o1["sharpe"] >= o0["sharpe"] - 0.02 and o1["dd"] <= o0["dd"] + 1e-9 \
and f1["sharpe"] >= f0["sharpe"] - 0.02 and f1["dd"] <= f0["dd"] + 1e-9
print(f" => {'MIGLIORA (promosso)' if ok else 'NON migliora (bocciato)'}")
print(f" peso pairs ETH/BTC: 1h {w1.get('PR_ETHBTC',0)*100:.1f}% + 15m "
f"{w1.get('PR_ETHBTC_15M',0)*100:.1f}% + 30m {w1.get('PR_ETHBTC_30M',0)*100:.1f}% "
f"= {(w1.get('PR_ETHBTC',0)+w1.get('PR_ETHBTC_15M',0)+w1.get('PR_ETHBTC_30M',0))*100:.1f}% su 7 coppie")
print("\n[3] ROBUSTEZZA AI COSTI (30m standalone, Sharpe per fee RT/coppia):")
for fee, lbl in [(0.001, "0.20% 1x"), (0.002, "0.40% 2x"), (0.003, "0.60% 3x"),
(0.004, "0.80% 4x"), (0.006, "1.20% 6x")]:
r = pairs_sim_flat("ETH", "BTC", tf="30m", **WIN_30M, flat_skip=True, fee_rt=fee)
print(f" {lbl:<10} Sharpe {r['sharpe']:>6.2f}")
if __name__ == "__main__":
main()
+228
View File
@@ -0,0 +1,228 @@
"""Verifica indipendente + ricerca PAIRS / SPREAD MEAN-REVERSION fra cripto.
Famiglia nuova market-neutral (distinta da tutto l'esistente, single-asset).
Idea: il log-ratio di due cripto oscilla attorno alla media; z-score estremo -> rientra.
Engine ONESTO (no look-ahead, verificato):
- r[i] = log(closeA[i]/closeB[i]); ma/sd = rolling(n) su r -> usano solo r[<=i].
- z[i] = (r[i]-ma[i])/sd[i]. ENTRY a close[i] (eseguibile):
z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio.
- EXIT quando |z[j]| <= z_exit (rientro) o time-limit max_bars, a close[j].
- pairs = 2 GAMBE -> fee = 2*fee_rt*lev (0.20% RT/coppia a fee_rt=0.001), il doppio
del single-asset. Rendimento neutral = retA*d - retB*d (notional uguale per gamba).
- non-overlap, capitale composto. Filtro candele sporche: salta salti |dr|>jump_max.
- Ritorno riportato come CAGR e Sharpe ANNUALIZZATO sul tempo reale (no sqrt(n_trade)).
"""
from __future__ import annotations
import sys
from pathlib import Path
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
FEE_RT, LEV, POS, OOS_FRAC = 0.001, 3.0, 0.15, 0.30
BARS_YEAR = 8760 # 1h
def aligned(a: str, b: str, tf: str = "1h"):
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(columns=lambda x: x + "_a" if x != "timestamp" else x)
db = load_data(b, tf)[["timestamp", "close"]].rename(columns={"close": "close_b"})
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
return m
def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS, split_frac=0.0):
m = aligned(a, b, tf)
ca, cb = m["close_a"].values, m["close_b"].values
r = np.log(ca / cb)
dr = np.abs(np.diff(r, prepend=r[0])) # salto 1-bar del log-ratio
ma = pd.Series(r).rolling(n).mean().values
sd = pd.Series(r).rolling(n).std().values
z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i]
ts = m["dt"]; N = len(r)
split = int(N * split_frac)
fee = 2 * fee_rt * lev # 2 gambe
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = 0; rets = []; yearly = {}; yearly_n = {}
eq_ts: list = []; eq_v: list = []
for i in range(n + 1, N - 1):
if i < split or np.isnan(z[i]) or dr[i] > jump_max:
continue
if i <= last:
continue
if z[i] <= -z_in:
d = 1
elif z[i] >= z_in:
d = -1
else:
continue
# exit: |z|<=z_exit o max_bars
j = min(i + max_bars, N - 1)
for k in range(1, max_bars + 1):
jj = i + k
if jj >= N:
j = N - 1; break
if abs(z[jj]) <= z_exit:
j = jj; break
j = jj
retA = (ca[j] - ca[i]) / ca[i]
retB = (cb[j] - cb[i]) / cb[i]
ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa)
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
yearly_n[ts.iloc[i].year] = yearly_n.get(ts.iloc[i].year, 0) + 1
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(BARS_YEAR / np.mean([max_bars])) ) if len(rets) > 1 and np.std(rets) > 0 else 0.0
# Sharpe annualizzato sul tempo reale: usa rendimenti per-trade scalati alla frequenza media
if len(rets) > 1 and np.std(rets) > 0:
trades_per_year = trades / yrs_span
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades_per_year))
ret_tot = (cap / 1000 - 1) * 100
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot, cagr=cagr,
dd=dd * 100, sharpe=sharpe, yearly=yearly, yearly_n=yearly_n,
eq_ts=eq_ts, eq_v=eq_v)
def aligned_ohlc(a: str, b: str, tf: str = "1h"):
"""Come aligned ma con OHLC di ENTRAMBE le gambe (serve a rilevare candele flat)."""
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_a" if x != "timestamp" else x)
db = load_data(b, tf)[["timestamp", "open", "high", "low", "close"]].rename(
columns=lambda x: x + "_b" if x != "timestamp" else x)
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
return m
def is_flat_ohlc(o, h, l, c):
"""Candela flat (O=H=L=C): prezzo fermo / liquidita' zero -> fill non eseguibile."""
return (o == h) & (h == l) & (l == c)
def pairs_sim_flat(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS, split_frac=0.0,
flat_skip=False, scan_buffer=192):
"""Engine pairs GENERALIZZATO con opzione flat-skip LIVE-REALIZABLE.
Identico a pairs_sim quando flat_skip=False (regression-lock verificato).
Con flat_skip=True:
- ENTRY: saltata se la barra d'ingresso e' flat in UNA delle due gambe (prezzo stale).
- EXIT: la condizione di uscita (|z|<=z_exit O bars>=max_bars) arma 'exit_ready';
si esce al CLOSE della PRIMA barra PULITA successiva (mai a un prezzo passato).
scan_buffer = barre extra oltre max_bars concesse per trovare la barra pulita.
Questa e' la stessa regola implementata nel PairsWorker live (flat_skip) -> parita'.
"""
m = aligned_ohlc(a, b, tf)
ca, cb = m["close_a"].values, m["close_b"].values
N = len(ca)
if flat_skip:
flat = (is_flat_ohlc(m["open_a"].values, m["high_a"].values, m["low_a"].values, ca)
| is_flat_ohlc(m["open_b"].values, m["high_b"].values, m["low_b"].values, cb))
else:
flat = np.zeros(N, dtype=bool)
r = np.log(ca / cb)
dr = np.abs(np.diff(r, prepend=r[0]))
ma = pd.Series(r).rolling(n).mean().values
sd = pd.Series(r).rolling(n).std().values
z = (r - ma) / np.where(sd == 0, np.nan, sd)
ts = m["dt"]
split = int(N * split_frac)
fee = 2 * fee_rt * lev
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = 0; rets = []; yearly = {}; yearly_n = {}
eq_ts, eq_v = [], []
n_skip_entry = 0
kmax = max_bars + (scan_buffer if flat_skip else 0)
for i in range(n + 1, N - 1):
if i < split or np.isnan(z[i]) or dr[i] > jump_max or i <= last:
continue
if z[i] <= -z_in:
d = 1
elif z[i] >= z_in:
d = -1
else:
continue
if flat[i]:
n_skip_entry += 1
continue # niente ingresso su barra stale
# uscita live-realizable: arma a |z|<=z_exit o max_bars, esci alla prima barra pulita
exit_ready = False; j = i
for k in range(1, kmax + 1):
jj = i + k
if jj >= N:
j = N - 1; break
if not exit_ready and (abs(z[jj]) <= z_exit or k >= max_bars):
exit_ready = True
if exit_ready and not flat[jj]:
j = jj; break
j = jj
retA = (ca[j] - ca[i]) / ca[i]
retB = (cb[j] - cb[i]) / cb[i]
ret = (retA - retB) * d * lev - fee
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
yearly_n[ts.iloc[i].year] = yearly_n.get(ts.iloc[i].year, 0) + 1
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
sharpe = 0.0
if len(rets) > 1 and np.std(rets) > 0:
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades / yrs_span))
ret_tot = (cap / 1000 - 1) * 100
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot,
cagr=cagr, dd=dd * 100, sharpe=sharpe, yearly=yearly, yearly_n=yearly_n,
eq_ts=eq_ts, eq_v=eq_v, n_skip_entry=n_skip_entry)
def check_no_lookahead():
"""Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita')."""
m = aligned("ETH", "BTC")
r = np.log(m["close_a"].values / m["close_b"].values)
n = 50; i = 1000
z_i = (r[i] - pd.Series(r).rolling(n).mean().values[i]) / pd.Series(r).rolling(n).std().values[i]
r2 = r.copy(); r2[i + 1:] += 0.5 # stravolge il futuro
z_i2 = (r2[i] - pd.Series(r2).rolling(n).mean().values[i]) / pd.Series(r2).rolling(n).std().values[i]
print(f" no-look-ahead: z[i]={z_i:.6f} vs z[i] con futuro perturbato={z_i2:.6f} -> "
f"{'OK (identico)' if abs(z_i - z_i2) < 1e-9 else 'VIOLAZIONE!'}")
def main():
print("=" * 104)
print(f" PAIRS spread reversion — NETTO fee 0.20% RT/coppia (2 gambe), leva {LEV:.0f}x, OOS ultimo {int(OOS_FRAC*100)}%")
print("=" * 104)
check_no_lookahead()
pairs = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("SOL", "ETH"),
("BNB", "BTC"), ("XRP", "BTC"), ("SOL", "BTC"), ("DOGE", "BTC")]
print(f"\n {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'FULL%':>8s}{'OOS%':>8s}{'CAGR%':>7s}"
f"{'DD%':>6s}{'oDD%':>6s}{'Shrp':>6s}{'anni+':>7s}{'fee0.4%RT':>11s}")
print(" " + "-" * 96)
for a, b in pairs:
f = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72)
o = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, split_frac=1 - OOS_FRAC)
hi = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, fee_rt=0.002) # 0.4% RT/coppia
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['ret']:>+8.0f}{o['ret']:>+8.0f}"
f"{f['cagr']:>7.0f}{f['dd']:>6.0f}{o['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}"
f"{hi['ret']:>+11.0f}")
# correlazione con BTC daily (market-neutrality) sulla coppia migliore
print("\n Verifica market-neutrality ETH/BTC: per-anno")
f = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.5, max_bars=72)
print(" " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(f["yearly"].items())))
if __name__ == "__main__":
main()
@@ -0,0 +1,199 @@
"""Ricerca PRE-REGISTRATA: disaster-cap z-score (z_stop) per la famiglia PAIRS.
Ipotesi pre-registrata: uscita immediata al close della barra se |z| >= z_stop
dopo l'ingresso taglia la coda da structural-break senza toccare i trade normali
(che vivono fra z_exit e z_in).
Griglia PRE-REGISTRATA (unica, completa NIENTE varianti a posteriori):
- 5 coppie 1h (config universale n=50 z_in=2.0 z_exit=0.75 max_bars=72):
z_stop in {3.0, 3.5, 4.0, 5.0}
- ETH/BTC 15m flat_skip (n=66 z_in=1.674 z_exit=1.0 max_bars=35):
z_stop in {2.5, 3.0, 3.5, 4.0}
Split: TRAIN = entry prima del 2023-11-01, OOS = dopo (convenzione progetto).
Engine: copia FEDELE di pairs_research.pairs_sim / pairs_sim_flat (stessa
matematica, fee 2 gambe = 2*fee_rt*lev) + parametro z_stop. Causalita': lo z
usato per l'exit alla barra j e' lo stesso z[j] causale (rolling su r[<=j])
gia' usato dall'exit |z|<=z_exit.
REGRESSION-LOCK obbligatorio (eseguito in main, si ferma se fallisce):
z_stop=None deve riprodurre ESATTAMENTE pairs_sim (ETH/BTC 1h) e
pairs_sim_flat (ETH/BTC 15m flat_skip): stesso n trade, stesso ret.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.pairs_research import ( # noqa: E402
FEE_RT, LEV, POS, aligned_ohlc, is_flat_ohlc, pairs_sim, pairs_sim_flat,
)
SPLIT_DT = pd.Timestamp("2023-11-01", tz="UTC")
def pairs_sim_zstop(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72,
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS,
z_stop=None, t0=None, t1=None,
flat_skip=False, scan_buffer=192):
"""Copia fedele dell'engine pairs (pairs_sim_flat, che con flat_skip=False
e' identico a pairs_sim — regression-lock in main) + disaster-cap z_stop.
z_stop: se non None, l'exit si arma anche quando |z[jj]| >= z_stop
(structural break: lo spread diverge oltre l'ingresso). Stessa convenzione
causale e stesso fill (close della barra) dell'exit |z|<=z_exit.
t0/t1: finestra sul timestamp della barra di ENTRY (train/OOS split).
"""
m = aligned_ohlc(a, b, tf)
ca, cb = m["close_a"].values, m["close_b"].values
N = len(ca)
if flat_skip:
flat = (is_flat_ohlc(m["open_a"].values, m["high_a"].values, m["low_a"].values, ca)
| is_flat_ohlc(m["open_b"].values, m["high_b"].values, m["low_b"].values, cb))
else:
flat = np.zeros(N, dtype=bool)
r = np.log(ca / cb)
dr = np.abs(np.diff(r, prepend=r[0]))
ma = pd.Series(r).rolling(n).mean().values
sd = pd.Series(r).rolling(n).std().values
z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i]
ts = m["dt"]
tsv = ts.values # datetime64 per filtro finestra
t0v = np.datetime64(t0.tz_convert(None)) if t0 is not None else None
t1v = np.datetime64(t1.tz_convert(None)) if t1 is not None else None
fee = 2 * fee_rt * lev # 2 gambe
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = n_stop = 0
rets = []; rets_raw = []
eq_ts, eq_v = [], []
kmax = max_bars + (scan_buffer if flat_skip else 0)
for i in range(n + 1, N - 1):
if np.isnan(z[i]) or dr[i] > jump_max or i <= last:
continue
if t0v is not None and tsv[i] < t0v:
continue
if t1v is not None and tsv[i] >= t1v:
continue
if z[i] <= -z_in:
d = 1
elif z[i] >= z_in:
d = -1
else:
continue
if flat[i]:
continue # niente ingresso su barra stale
# exit: |z|<=z_exit, max_bars, o DISASTER-CAP |z|>=z_stop; con flat_skip
# l'exit si arma e si esce alla prima barra pulita (live-realizable)
exit_ready = False; stopped = False; j = i
for k in range(1, kmax + 1):
jj = i + k
if jj >= N:
j = N - 1; break
if not exit_ready:
if z_stop is not None and abs(z[jj]) >= z_stop:
exit_ready = True; stopped = True
elif abs(z[jj]) <= z_exit or k >= max_bars:
exit_ready = True
if exit_ready and not flat[jj]:
j = jj; break
j = jj
retA = (ca[j] - ca[i]) / ca[i]
retB = (cb[j] - cb[i]) / cb[i]
ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa)
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; n_stop += stopped
rets.append(ret * pos); rets_raw.append(ret); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
# span temporale della finestra effettiva (per annualizzare lo Sharpe)
lo = ts.iloc[0] if t0 is None else max(ts.iloc[0], t0)
hi = ts.iloc[-1] if t1 is None else min(ts.iloc[-1], t1)
yrs_span = (hi - lo).days / 365.25 or 1
sharpe = 0.0
if len(rets) > 1 and np.std(rets) > 0:
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades / yrs_span))
ret_tot = (cap / 1000 - 1) * 100
worst = min(rets_raw) * 100 if rets_raw else 0.0
return dict(trades=trades, n_stop=n_stop, win=wins / trades * 100 if trades else 0,
ret=ret_tot, dd=dd * 100, sharpe=sharpe, worst=worst)
# ----------------------------------------------------------------------------- lock
def regression_lock():
"""z_stop=None deve riprodurre ESATTAVENTE l'engine canonico."""
ok = True
# 1h plain vs pairs_sim (config universale live z_exit=0.75)
ref = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.75, max_bars=72)
new = pairs_sim_zstop("ETH", "BTC", n=50, z_in=2.0, z_exit=0.75, max_bars=72,
z_stop=None, flat_skip=False)
m1 = (ref["trades"] == new["trades"]) and abs(ref["ret"] - new["ret"]) < 1e-9
print(f" LOCK 1h ETH/BTC vs pairs_sim: trades {ref['trades']} vs {new['trades']}, "
f"ret {ref['ret']:+.6f} vs {new['ret']:+.6f} -> {'OK' if m1 else 'FAIL'}")
ok &= m1
# 15m flat_skip vs pairs_sim_flat
ref = pairs_sim_flat("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
max_bars=35, flat_skip=True)
new = pairs_sim_zstop("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
max_bars=35, z_stop=None, flat_skip=True)
m2 = (ref["trades"] == new["trades"]) and abs(ref["ret"] - new["ret"]) < 1e-9
print(f" LOCK 15m ETH/BTC vs pairs_sim_flat: trades {ref['trades']} vs {new['trades']}, "
f"ret {ref['ret']:+.6f} vs {new['ret']:+.6f} -> {'OK' if m2 else 'FAIL'}")
ok &= m2
return ok
# ----------------------------------------------------------------------------- main
PAIRS_1H = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("BTC", "LTC"), ("ETH", "SOL")]
GRID_1H = [None, 3.0, 3.5, 4.0, 5.0]
GRID_15M = [None, 2.5, 3.0, 3.5, 4.0]
def run_cell(a, b, win, z_stop, **kw):
t0, t1 = (None, SPLIT_DT) if win == "TRAIN" else (SPLIT_DT, None)
return pairs_sim_zstop(a, b, z_stop=z_stop, t0=t0, t1=t1, **kw)
def main():
print("=" * 100)
print(" PAIRS disaster-cap z_stop — ricerca PRE-REGISTRATA (griglia fissa, tutti i risultati)")
print(f" split TRAIN < {SPLIT_DT.date()} <= OOS | fee 2 gambe {2*FEE_RT*LEV*100:.2f}% | lev {LEV:.0f}x pos {POS}")
print("=" * 100)
print("\nREGRESSION-LOCK (z_stop=None == engine canonico):")
if not regression_lock():
print("\n LOCK FALLITO — STOP."); sys.exit(1)
hdr = (f" {'z_stop':>7s} | {'trd':>5s} {'stop':>5s} {'ret%':>9s} {'Shrp':>6s} "
f"{'DD%':>6s} {'worst%':>8s}")
for a, b in PAIRS_1H:
kw = dict(tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72, flat_skip=False)
print(f"\n{'-'*100}\n {a}/{b} 1h (n=50 z_in=2.0 z_exit=0.75 max_bars=72)")
for win in ("TRAIN", "OOS"):
print(f" [{win}]\n{hdr}")
for zs in GRID_1H:
r = run_cell(a, b, win, zs, **kw)
lab = "None" if zs is None else f"{zs:.1f}"
print(f" {lab:>7s} | {r['trades']:>5d} {r['n_stop']:>5d} {r['ret']:>+9.1f} "
f"{r['sharpe']:>6.2f} {r['dd']:>6.2f} {r['worst']:>+8.2f}")
a, b = "ETH", "BTC"
kw = dict(tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35, flat_skip=True)
print(f"\n{'-'*100}\n {a}/{b} 15m flat_skip (n=66 z_in=1.674 z_exit=1.0 max_bars=35)")
for win in ("TRAIN", "OOS"):
print(f" [{win}]\n{hdr}")
for zs in GRID_15M:
r = run_cell(a, b, win, zs, **kw)
lab = "None" if zs is None else f"{zs:.1f}"
print(f" {lab:>7s} | {r['trades']:>5d} {r['n_stop']:>5d} {r['ret']:>+9.1f} "
f"{r['sharpe']:>6.2f} {r['dd']:>6.2f} {r['worst']:>+8.2f}")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More