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,344 @@
|
||||
"""
|
||||
Game engine — "Blind Traders" tournament.
|
||||
|
||||
100 agenti ricevono due serie anonime (A, B) — in realta' BTC e ETH 1h — e
|
||||
propongono strategie senza sapere cosa sono. L'orchestratore (questo motore)
|
||||
valuta ogni strategia con un backtest deterministico, causale e fee-aware, e
|
||||
assegna un punteggio su %win + PNL con vincolo >=10 trade/mese.
|
||||
|
||||
Tutto causale (nessun look-ahead): i segnali alla barra i usano solo dati
|
||||
fino a close[i]; l'ingresso e' a close[i], le uscite TP/SL/max_bars intrabar
|
||||
dalle barre successive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.001 # 0.10% round-trip (taker Deribit, baseline progetto)
|
||||
TF_BPM = {"5m": 12 * 24 * 30, "15m": 4 * 24 * 30, "30m": 2 * 24 * 30,
|
||||
"1h": 24 * 30, "2h": 12 * 30, "4h": 6 * 30, "1d": 30} # barre/mese per tf
|
||||
MIN_TRADES_PER_MONTH = 10.0
|
||||
# timeframe non presenti come parquet -> resamplati da una base (open=first,
|
||||
# high=max, low=min, close=last, volume=sum). Permette "timing diversi" nel gioco.
|
||||
_RESAMPLE = {"30m": ("15m", "30min"), "2h": ("1h", "2h"),
|
||||
"4h": ("1h", "4h"), "1d": ("1h", "1D")}
|
||||
|
||||
# Slippage per LATO (oltre alle fee). 0 = come prima. Single-leg paga 2 lati
|
||||
# (ingresso+uscita), i pairs ne pagano 4 (2 gambe x 2 lati).
|
||||
_SLIP = 0.0
|
||||
|
||||
|
||||
def set_slippage(slip_per_side: float):
|
||||
global _SLIP
|
||||
_SLIP = float(slip_per_side)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Dati anonimizzati
|
||||
# --------------------------------------------------------------------------
|
||||
def _load_tf(asset: str, tf: str):
|
||||
"""Carica un asset al timeframe tf (parquet diretto, o resample da una base)."""
|
||||
if tf in _RESAMPLE:
|
||||
base_tf, rule = _RESAMPLE[tf]
|
||||
d = load_data(asset, base_tf).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"])
|
||||
g = g.reset_index()
|
||||
g["datetime"] = g["dt"]
|
||||
g["timestamp"] = (g["dt"].astype("int64") // 1_000_000)
|
||||
return g.drop(columns=["dt"])
|
||||
return load_data(asset, tf).copy()
|
||||
|
||||
|
||||
def load_anon(tf: str = "1h"):
|
||||
"""Carica BTC->A, ETH->B allineati sull'intersezione temporale.
|
||||
|
||||
Ritorna un dict con array OHLC per A e B + datetime. I nomi reali NON
|
||||
compaiono: gli agenti vedono solo 'A' e 'B'.
|
||||
"""
|
||||
btc = _load_tf("BTC", tf)
|
||||
eth = _load_tf("ETH", tf)
|
||||
for d in (btc, eth):
|
||||
d["dt"] = pd.to_datetime(d["datetime"])
|
||||
btc = btc.set_index("dt")
|
||||
eth = eth.set_index("dt")
|
||||
idx = btc.index.intersection(eth.index)
|
||||
btc = btc.loc[idx].sort_index()
|
||||
eth = eth.loc[idx].sort_index()
|
||||
out = {"dt": idx.to_numpy()}
|
||||
for name, d in (("A", btc), ("B", eth)):
|
||||
out[name] = {
|
||||
"open": d["open"].to_numpy(float),
|
||||
"high": d["high"].to_numpy(float),
|
||||
"low": d["low"].to_numpy(float),
|
||||
"close": d["close"].to_numpy(float),
|
||||
"volume": d["volume"].to_numpy(float),
|
||||
}
|
||||
out["n"] = len(idx)
|
||||
out["tf"] = tf
|
||||
out["bpm"] = TF_BPM[tf]
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Indicatori causali (vettorizzati)
|
||||
# --------------------------------------------------------------------------
|
||||
def _roll_mean(x, w):
|
||||
return pd.Series(x).rolling(w).mean().to_numpy()
|
||||
|
||||
|
||||
def _roll_std(x, w):
|
||||
return pd.Series(x).rolling(w).std(ddof=0).to_numpy()
|
||||
|
||||
|
||||
def _ema(x, w):
|
||||
return pd.Series(x).ewm(span=w, adjust=False).mean().to_numpy()
|
||||
|
||||
|
||||
def _atr(high, low, close, w=14):
|
||||
pc = np.roll(close, 1)
|
||||
pc[0] = close[0]
|
||||
tr = np.maximum(high - low, np.maximum(np.abs(high - pc), np.abs(low - pc)))
|
||||
return pd.Series(tr).rolling(w).mean().to_numpy()
|
||||
|
||||
|
||||
def _rsi(close, w=14):
|
||||
d = np.diff(close, prepend=close[0])
|
||||
up = np.where(d > 0, d, 0.0)
|
||||
dn = np.where(d < 0, -d, 0.0)
|
||||
ru = pd.Series(up).ewm(alpha=1 / w, adjust=False).mean().to_numpy()
|
||||
rd = pd.Series(dn).ewm(alpha=1 / w, adjust=False).mean().to_numpy()
|
||||
rs = ru / (rd + 1e-12)
|
||||
return 100 - 100 / (1 + rs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Famiglie di segnale -> array di posizione desiderata {-1,0,+1} alla barra i
|
||||
# (causale: usa solo dati fino a close[i]). +1 = long, -1 = short.
|
||||
# --------------------------------------------------------------------------
|
||||
def _signal_single(o, family, p):
|
||||
"""Segnale per una singola serie. Ritorna (pos_target, atr)."""
|
||||
close = o["close"]
|
||||
high, low = o["high"], o["low"]
|
||||
n = len(close)
|
||||
atr = _atr(high, low, close, 14)
|
||||
pos = np.zeros(n)
|
||||
lb = max(2, int(p["lookback"]))
|
||||
thr = float(p["entry_thr"])
|
||||
sign = 1 if p.get("direction", "reversion") == "trend" else -1
|
||||
|
||||
if family == "zscore":
|
||||
ma = _roll_mean(close, lb)
|
||||
sd = _roll_std(close, lb)
|
||||
z = (close - ma) / (sd + 1e-12)
|
||||
pos = np.where(z > thr, sign * -1.0, np.where(z < -thr, sign * 1.0, 0.0))
|
||||
elif family == "breakout":
|
||||
hh = pd.Series(high).rolling(lb).max().shift(1).to_numpy()
|
||||
ll = pd.Series(low).rolling(lb).min().shift(1).to_numpy()
|
||||
up = close > hh
|
||||
dn = close < ll
|
||||
# trend: break-up=long ; reversion: break-up=short
|
||||
pos = np.where(up, sign * 1.0, np.where(dn, sign * -1.0, 0.0))
|
||||
elif family == "ma_cross":
|
||||
fast = _ema(close, lb)
|
||||
slow = _ema(close, max(lb + 2, int(lb * p.get("slow_mult", 3))))
|
||||
pos = np.where(fast > slow, sign * 1.0, sign * -1.0)
|
||||
elif family == "rsi":
|
||||
r = _rsi(close, lb)
|
||||
hi = 50 + thr * 10
|
||||
lo = 50 - thr * 10
|
||||
pos = np.where(r > hi, sign * -1.0, np.where(r < lo, sign * 1.0, 0.0))
|
||||
elif family == "momentum":
|
||||
ret = close / np.roll(close, lb) - 1
|
||||
ret[:lb] = 0
|
||||
pos = np.where(ret > thr / 100, sign * 1.0,
|
||||
np.where(ret < -thr / 100, sign * -1.0, 0.0))
|
||||
else:
|
||||
raise ValueError(f"unknown family {family}")
|
||||
pos = np.nan_to_num(pos)
|
||||
return pos, atr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Backtest single-series (long/short con TP/SL/max_bars intrabar)
|
||||
# --------------------------------------------------------------------------
|
||||
def _backtest_single(o, pos, atr, p, fee=FEE_RT):
|
||||
close, high, low = o["close"], o["high"], o["low"]
|
||||
n = len(close)
|
||||
tp_atr = float(p.get("tp_atr", 2.0))
|
||||
sl_atr = float(p.get("sl_atr", 2.0))
|
||||
max_bars = int(p.get("max_bars", 24))
|
||||
rets = [] # net return per trade
|
||||
# warmup
|
||||
start = max(int(p["lookback"]) + 15, 20)
|
||||
# indici candidati: solo barre con segnale != 0 (salta le barre flat)
|
||||
cand = np.flatnonzero(pos[start:n - 1]) + start
|
||||
ci = 0
|
||||
nc = len(cand)
|
||||
while ci < nc:
|
||||
i = int(cand[ci])
|
||||
d = pos[i]
|
||||
if d == 0 or np.isnan(atr[i]) or atr[i] <= 0:
|
||||
ci += 1
|
||||
continue
|
||||
entry = close[i]
|
||||
a = atr[i]
|
||||
if d > 0:
|
||||
tp = entry + tp_atr * a
|
||||
sl = entry - sl_atr * a
|
||||
else:
|
||||
tp = entry - tp_atr * a
|
||||
sl = entry + sl_atr * a
|
||||
exit_px = None
|
||||
j = i + 1
|
||||
end = min(n - 1, i + max_bars)
|
||||
while j <= end:
|
||||
hi, lo = high[j], low[j]
|
||||
if d > 0:
|
||||
if lo <= sl: # SL prioritario
|
||||
exit_px = sl
|
||||
break
|
||||
if hi >= tp:
|
||||
exit_px = tp
|
||||
break
|
||||
else:
|
||||
if hi >= sl:
|
||||
exit_px = sl
|
||||
break
|
||||
if lo <= tp:
|
||||
exit_px = tp
|
||||
break
|
||||
j += 1
|
||||
if exit_px is None:
|
||||
exit_px = close[end]
|
||||
j = end
|
||||
gross = d * (exit_px - entry) / entry
|
||||
net = gross - fee - 2 * _SLIP # 2 lati di slippage
|
||||
rets.append(net)
|
||||
# salta al primo ingresso candidato OLTRE l'uscita (no overlap)
|
||||
ci = int(np.searchsorted(cand, j + 1, side="left"))
|
||||
return np.array(rets)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Backtest cross-series (pairs market-neutral sullo z del log-ratio)
|
||||
# --------------------------------------------------------------------------
|
||||
def _backtest_pairs(A, B, p, fee=FEE_RT):
|
||||
a, b = A["close"], B["close"]
|
||||
n = len(a)
|
||||
lb = max(5, int(p["lookback"]))
|
||||
z_in = float(p["entry_thr"])
|
||||
z_exit = float(p.get("exit_thr", 0.5))
|
||||
max_bars = int(p.get("max_bars", 72))
|
||||
lr = np.log(a / b)
|
||||
ma = _roll_mean(lr, lb)
|
||||
sd = _roll_std(lr, lb)
|
||||
z = (lr - ma) / (sd + 1e-12)
|
||||
rets = []
|
||||
start = max(lb + 5, 20)
|
||||
zabs = np.abs(z)
|
||||
zabs[:start] = 0.0
|
||||
zabs[np.isnan(zabs)] = 0.0
|
||||
cand = np.flatnonzero(zabs[:n - 1] > z_in)
|
||||
ci = 0
|
||||
nc = len(cand)
|
||||
while ci < nc:
|
||||
i = int(cand[ci])
|
||||
d = -1 if z[i] > z_in else 1 # spread alto -> short A/long B ; basso -> long A/short B
|
||||
ea, eb = a[i], b[i]
|
||||
j = i + 1
|
||||
end = min(n - 1, i + max_bars)
|
||||
while j <= end:
|
||||
if abs(z[j]) <= z_exit:
|
||||
break
|
||||
j += 1
|
||||
j = min(j, end)
|
||||
# PnL = gamba A (dir d) + gamba B (dir -d), fee su 2 gambe
|
||||
ra = d * (a[j] - ea) / ea
|
||||
rb = -d * (b[j] - eb) / eb
|
||||
net = ra + rb - 2 * fee - 4 * _SLIP # 2 gambe x 2 lati di slippage
|
||||
rets.append(net)
|
||||
ci = int(np.searchsorted(cand, j + 1, side="left"))
|
||||
return np.array(rets)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Valutazione + scoring
|
||||
# --------------------------------------------------------------------------
|
||||
def evaluate(data, spec, sl=None, fee=FEE_RT):
|
||||
"""Valuta una spec di strategia su uno slice [start,end) (sl=slice di indici).
|
||||
|
||||
spec = {family, series, params{...}}. Ritorna dict metriche.
|
||||
"""
|
||||
family = spec["family"]
|
||||
series = spec.get("series", "A")
|
||||
p = spec["params"]
|
||||
|
||||
def _slice(o):
|
||||
if sl is None:
|
||||
return o
|
||||
s, e = sl
|
||||
return {k: v[s:e] for k, v in o.items()}
|
||||
|
||||
if family == "pairs":
|
||||
A = _slice(data["A"])
|
||||
B = _slice(data["B"])
|
||||
rets = _backtest_pairs(A, B, p, fee)
|
||||
nbars = len(A["close"])
|
||||
else:
|
||||
o = _slice(data[series])
|
||||
pos, atr = _signal_single(o, family, p)
|
||||
rets = _backtest_single(o, pos, atr, p, fee)
|
||||
nbars = len(o["close"])
|
||||
|
||||
n_tr = len(rets)
|
||||
months = nbars / data.get("bpm", TF_BPM["1h"])
|
||||
tpm = n_tr / months if months > 0 else 0.0
|
||||
if n_tr == 0:
|
||||
return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0,
|
||||
sharpe=0.0, avg_ret=0.0, qualified=False, fitness=-1e6)
|
||||
win_rate = float(np.mean(rets > 0))
|
||||
pnl = float(np.sum(rets)) * 100 # PnL additivo (notional fisso), %
|
||||
equity = float(np.prod(1 + rets) - 1) * 100 # equity compounding, %
|
||||
avg = float(np.mean(rets)) * 100
|
||||
sharpe = float(np.mean(rets) / (np.std(rets) + 1e-12) * np.sqrt(tpm * 12)) \
|
||||
if np.std(rets) > 0 else 0.0
|
||||
qualified = tpm >= MIN_TRADES_PER_MONTH
|
||||
# fitness: PNL domina, win% come spinta secondaria; squalifica se pochi trade
|
||||
fitness = pnl + 50.0 * win_rate
|
||||
if not qualified:
|
||||
fitness = -1e6 + pnl # ordinati ma fuori gioco
|
||||
return dict(n_trades=n_tr, win_rate=win_rate, pnl_pct=pnl, equity_pct=equity,
|
||||
tpm=tpm, sharpe=sharpe, avg_ret=avg, qualified=qualified,
|
||||
fitness=fitness)
|
||||
|
||||
|
||||
# Split a 3: TRAIN (hill-climb) / VALID (cull+rank dell'orchestratore) / TEST (OOS puro)
|
||||
def splits3(data, train_frac=0.60, valid_frac=0.20):
|
||||
n = data["n"]
|
||||
c1 = int(n * train_frac)
|
||||
c2 = int(n * (train_frac + valid_frac))
|
||||
return (0, c1), (c1, c2), (c2, n)
|
||||
|
||||
|
||||
# compat: split a 2 (train/oos)
|
||||
def splits(data, train_frac=0.70):
|
||||
n = data["n"]
|
||||
cut = int(n * train_frac)
|
||||
return (0, cut), (cut, n)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
data = load_anon("1h")
|
||||
print("loaded", data["n"], "bars,", data["dt"][0], "->", data["dt"][-1])
|
||||
tr, oos = splits(data)
|
||||
demo = {"family": "zscore", "series": "B",
|
||||
"params": {"lookback": 20, "entry_thr": 2.0, "direction": "reversion",
|
||||
"tp_atr": 1.5, "sl_atr": 2.0, "max_bars": 24}}
|
||||
print("TRAIN", evaluate(data, demo, tr))
|
||||
print("OOS ", evaluate(data, demo, oos))
|
||||
Reference in New Issue
Block a user