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:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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<SMA100. Diversificare su 3
|
||||
asset invece di 2 ha quasi dimezzato il DD (40%→26%) alzando il ritorno.</p>""", g_rot, t_rot)
|
||||
|
||||
c_pr = card("PR01 — Pairs Reversion (ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL + ETH/BTC 15m)",
|
||||
B("pairs", "PAIRS") + real, """
|
||||
<p><b>Market-neutral</b>: quando il rapporto fra due asset si allontana troppo dalla sua media
|
||||
(|z| del log-ratio ≥ 2), compra la gamba debole e shorta la forte; chiude quando il rapporto
|
||||
rientra (|z| ≤ 0.75) o dopo 72 barre. Config <b>universale</b> per tutte le coppie (niente tuning
|
||||
per-coppia = anti-overfit). Correlazione col mercato ~0.05: rende anche quando il mercato è fermo.
|
||||
Fee su 2 gambe. Senza stop per design → position size ridotto a 0.20 (esposizione ≈ validato).</p>
|
||||
<p class='sub'><b>BLEND timeframe (nuovo, 2026-06-09)</b>: ETH/BTC gira anche a <b>15m</b> accanto al 1h
|
||||
(config n=66, |z|≥1.67, exit |z|≤1.0 o 35 barre). Origine: gioco "Blind Traders" (100 agenti ciechi
|
||||
su dati anonimi); testato col gate PORT06 — <b>decorrelato dal 1h (corr 0.37)</b>, robusto (16/16),
|
||||
e l'edge regge anche filtrando le candele flat ETH 15m (<b>flat-skip</b>: niente ingresso/uscita su
|
||||
barre stale O=H=L=C). Worker validato (replay == backtest). A <b>mezza size</b> (blend-tilt prudente
|
||||
sul caveat slippage): porta il PORT06 a FULL Sharpe ~7.2 / OOS ~9.7.</p>
|
||||
<p class='sub'>Esecuzione reale a 2 gambe su Deribit testnet (<code>PairsExecutionClient</code>):
|
||||
open/close long A / short B, leg-risk unwind, mai <code>close_position</code>.</p>""", g_pr, t_pairs)
|
||||
|
||||
c_tsm = card("TSM01 — TSMOM (1d)", B("tsm", "TSM") + sim, """
|
||||
<p>Long sugli asset con <b>consenso pieno</b> di momentum su 3 orizzonti (3/6/12 mesi),
|
||||
gross 0.30, cash totale se BTC<SMA100. Mai un anno negativo nel backtest. Non è un motore di
|
||||
ritorno: è il <b>diversificatore</b> che lavora nei regimi in cui le fade soffrono.
|
||||
Attualmente flat by-design (risk-off).</p>""", g_tsm, t_tsm)
|
||||
|
||||
c_sh = card("SH01 — Shape-ML (BTC, ETH)", B("shape", "SHAPE") + real, """
|
||||
<p>Una <b>LogisticRegression</b> legge 17 feature della <i>forma</i> delle ultime 24 barre e
|
||||
predice il segno del rendimento a 12 barre; entra solo se la probabilità supera 0.58, esce a
|
||||
orizzonte. Training <b>walk-forward causale</b> (mai dati futuri). Win-rate ~50%: l'edge è
|
||||
nell'asimmetria, non nella frequenza. <b>Senza stop-loss by design</b> (ogni stop testato rompe
|
||||
l'edge): la coda si gestisce dimezzando il peso della famiglia (cap 5.88%).</p>
|
||||
<p class='sub'>Esecuzione reale single-leg su Deribit testnet: niente TP/SL, chiusura a orizzonte
|
||||
H=12 (market reduce-only), disaster-bracket on-book come unica protezione di coda — è il
|
||||
diversificatore più decorrelato del portafoglio.</p>
|
||||
<p class='sub'>Fix punto-10 (2026-06-07): il training live usa la storia COMPLETA dal parquet
|
||||
locale (il regime corto a 365g non era robusto: trade-rate 22% vs 10% validato).</p>""", g_sh, yearly_table(st_sh))
|
||||
|
||||
c_xs = card("XS01 — Cross-Sectional Reversion (8 asset)", B("xsec", "XSEC") + sim, """
|
||||
<p><b>Famiglia nuova (2026-06-09)</b>: ogni 12 ore classifica 8 crypto (BTC, ETH, LTC, ADA,
|
||||
SOL, BNB, XRP, DOGE) per rendimento a 48 ore e va <b>long i perdenti relativi / short i
|
||||
vincenti</b> (peso ∝ −(ret − media cross-section)), market-neutral gross 1. Cattura il
|
||||
<i>fattore</i> reversione cross-sezionale — distinto dai pairs (pairwise) e dai fade
|
||||
(single-asset): correlazione ~0 con entrambi. Gate PORT06: OOS Sharpe 9.66→10.07,
|
||||
FULL DD 3.68→3.46. Plateau robusto (lb 12-72 × hold 6-24 tutte OOS+).</p>
|
||||
<p class='sub'><b>Dispersion-gate (2026-06-10, v1.1.20)</b>: entra solo se la dispersione
|
||||
cross-section del momentum ≥ 0.0313 (mediana TRAIN) — la reversione paga quando c'è
|
||||
dispersione da far rientrare. Diagnostica monotona TRAIN e OOS, plateau p30-p70,
|
||||
standalone Sharpe 2.50→3.46 (regge fee 2x), PORT06 OOS 10.07→<b>10.37</b> a DD pari.
|
||||
Solo path live (backtest canonico non filtrato → il live farà meglio del backtest).</p>
|
||||
<p class='sub'><b>Phase-tranching (2026-06-11, v1.1.21)</b>: la fase del roll non-sovrapposto
|
||||
è arbitraria e da sola muove lo Sharpe daily 1.52-2.33 e il DD 14-33% (timing-luck) → live
|
||||
gira con <b>3 sub-book sfasati</b> di 4 barre su capitale comune (PnL/3 ciascuno): ensemble
|
||||
di fase SENZA parametri fittati. Gate con plateau (K=2 e K=3 entrambi promossi): PORT06
|
||||
OOS Sharpe 10.07→10.15, DD 1.48→1.38 a FULL pari. Worker validato esatto (K=1 == backtest;
|
||||
K=3 == unione fasi).</p>
|
||||
<p class='sub'>8 gambe → niente esecuzione reale: gira PAPER come i book multi-asset
|
||||
(il rumore di arrotondamento a €2k dominerebbe). Worker validato (replay == backtest esatto).</p>""",
|
||||
g_xs, t_xs)
|
||||
|
||||
html = f"""<!doctype html><html lang="it"><head><meta charset="utf-8">
|
||||
<title>PythagorasGoal — Strategie attive PORT06</title><style>{CSS}</style></head>
|
||||
<body><div class="wrap">
|
||||
<h1>PythagorasGoal — Strategie attive</h1>
|
||||
<p class="sub">Portafoglio live <b>PORT06</b> — definizione {n_def} sleeve; <b>pool live real-only 15 sleeve</b>
|
||||
(i 4 book multi-asset TR01/ROT02/TSM01/XS01 girano in statistica, fuori dal capitale-pool).
|
||||
Capitale pool €2.000, <b>leva 3x</b> (dal 2026-06-12), <b>real-truth ledger</b> ·
|
||||
v{ver} · generato {now} · backtest canonico: {hdr}</p>
|
||||
<div class="card">
|
||||
<p>Famiglie quasi <b>scorrelate</b> fra loro (fade↔honest ~0.05, pairs ~0.02-0.09,
|
||||
shape ~0.08, xsec ~0): la diversificazione è la leva anti-drawdown. Pesi equal con tetti
|
||||
per famiglia, ribilancio giornaliero su capitale pool condiviso. Fee Deribit 0.10% round-trip
|
||||
incluse ovunque; ogni meccanismo live è passato da un gate out-of-sample a livello di portafoglio.</p>
|
||||
<img src="data:image/png;base64,{g_w}"></div>
|
||||
|
||||
<h2>FADE — mean-reversion intraday 15m <span class="sub">(6 sleeve × 5.69%, swap 1h→15m dal 2026-06-12)</span></h2>
|
||||
<div class="note"><b>Tesi della famiglia:</b> sui perpetui crypto l'edge è la <i>reversione</i>:
|
||||
i movimenti estremi rientrano (i breakout falliscono — l'intera famiglia squeeze-breakout è stata
|
||||
scartata come artefatto di look-ahead). Le fade vendono l'eccesso e comprano il panico, con tre
|
||||
protezioni comuni: <b>filtro trend</b> (salta il segnale se il prezzo è oltre 3·ATR dalla EMA200 —
|
||||
non si fada un crollo/parabolica), <b>EXIT-16</b> (stop solo sul close confermato, vedi sotto) e
|
||||
<b>min_tp_frac</b> (salta i micro-trade col TP entro le fee).</div>
|
||||
{c_mr01}
|
||||
{c_mr02}
|
||||
{c_mr07}
|
||||
{c_e16}
|
||||
<h2>HONEST — long-only multi-regime <span class="sub">(3 sleeve × 5.69%)</span></h2>
|
||||
{c_dip}
|
||||
{c_tr}
|
||||
{c_rot}
|
||||
<h2>PAIRS — spread reversion market-neutral <span class="sub">(6 sleeve × 5.26%: 5 coppie 1h + ETH/BTC 15m a mezza size, famiglia ≤33%)</span></h2>
|
||||
{c_pr}
|
||||
<h2>TSM — trend-following multi-orizzonte <span class="sub">(1 sleeve × 5.69%)</span></h2>
|
||||
{c_tsm}
|
||||
<h2>XSEC — reversione cross-sectional <span class="sub">(1 sleeve × 5.69%)</span></h2>
|
||||
{c_xs}
|
||||
<h2>SHAPE — ML morfologico <span class="sub">(2 sleeve × 2.94%, famiglia ≤5.88%)</span></h2>
|
||||
{c_sh}
|
||||
|
||||
<h2>Metodologia</h2>
|
||||
<div class="card"><ul style="font-size:13.5px;line-height:1.7">
|
||||
<li><b>Fee sempre incluse</b>: 0.10% round-trip taker Deribit (misurate reali = assunte). Molte operazioni = morte per fee: ogni strategia regge lo stress a fee doppie.</li>
|
||||
<li><b>Niente look-ahead</b>: direzione e prezzo decisi solo con dati fino al close corrente; barre in formazione escluse (lezione EXIT-16). La famiglia squeeze (accuratezze 76-82%) è stata scartata proprio per questo artefatto.</li>
|
||||
<li><b>Gate out-of-sample</b>: nessun meccanismo va in produzione senza migliorare (o non degradare) il portafoglio FULL e OOS — robusto individualmente ≠ migliora PORT06.</li>
|
||||
<li><b>Esecuzione reale shadow (15 sleeve su 19)</b>: i 6 fade + DIP01 (single-leg, TP limit al livello, disaster-stop −30% on-book), i 6 pairs incl. <b>ETH/BTC 15m flat-skip</b> (2 gambe, leg-risk unwind) e i 2 SH01 (single-leg, exit a orizzonte, niente TP/SL) eseguono ordini reali su Deribit testnet. Restano simulati solo i book multi-asset TR01/ROT02/TSM01/XS01 (bloccati dal capitale: serve ~€20k per il rumore di arrotondamento). Fee reali verificate dai trade; divergenze sim/reale ≥100bps alertate su Telegram.</li>
|
||||
<li><b>Real-truth ledger (2026-06-10)</b>: il capitale dei worker eseguiti si aggiorna col PnL dei <b>fill reali</b> (fee reali incluse), non col sim — equity, pesi e notional derivano dai soldi veri sul conto; il sim resta diagnostica nel log (fallback dichiarato solo se il trade reale non è mai esistito). Le decisioni di entry/exit restano guidate dal feed.</li>
|
||||
<li><b>Verità d'esecuzione e netting (2026-06-11, v1.1.23-25)</b>: il tocco TP va confermato dal <b>book reale</b> (gate TP_PHANTOM: resting a zero-fill + prezzo lontano dal livello = wick fantasma del feed → exit soppressa); i ledger usano l'amount <b>fillato</b>, mai il richiesto; le chiusure sono <b>netting-aware</b> (residuo reduce-only cappato/respinto dal netting di conto → rieseguito in market puro: niente gambe orfane quando worker opposti condividono lo strumento). Reti di sicurezza: reconciler orario conto-vs-libri (alert <code>ACCOUNT_DRIFT</code>), eventi <code>NET_CLOSE</code>/<code>PAIR_LEG_ORPHAN</code>/<code>REAL_CLOSE_PARTIAL</code>, drift-monitor giornaliero per famiglia (warn sotto il p5 storico).</li>
|
||||
</ul></div>
|
||||
<p class="sub">Generato da <code>scripts/analysis/make_strategy_doc.py</code> — grafici da episodi reali sui dati parquet locali.</p>
|
||||
</div></body></html>"""
|
||||
|
||||
OUT.write_text(html)
|
||||
print(f"OK -> {OUT} ({OUT.stat().st_size//1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 })),
|
||||
}
|
||||
@@ -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 })),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user