Files
PythagorasGoal/scripts/games/grid_engine.py
T
Adriano Dal Pastro 8d69a0cef5 feat(games): sessioni 2-3 Blind Traders (opzioni/session/grid) + gate PORT06 e tooling reset
- Gioco GRID TRADERS (sessione 3, regola STRATEGIA_GRIGLIA.md): grid_engine
  (backtest causale fee-aware della griglia geometrica), grid_brief (digest
  anonimo per dimensionare la griglia), grid_arena (torneo 100 agenti);
  diario docs/diary/2026-06-10-grid-traders-game3.md
- Gioco OPZIONI: options_engine (BS + skew fittato + DVOL storica),
  options_arena, opt_calibrate (superficie premi REALE da cerbero-bite)
- Gioco SESSION: session_engine/session_arena (pattern orari intraday)
- arena: vincolo GAME_NO_LIVE=1 (vieta pairs e fade zscore/breakout/momentum
  gia' live, coercizione a trend/ma_cross) + normalize del candidato PRIMA
  della valutazione nel hill-climb
- Gate: grid_game_gate (griglia ETH vincitrice vs PORT06, mark-to-market),
  pairs30m_gate (ETH/BTC 30m ridondante col 15m gia' deployato?)
- reset_flatten: flatten one-shot del conto testnet per il reset portafoglio
- .gitignore: data/portfolio_paper_stats/ (stato runtime sleeve paper-only)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:49:17 +00:00

232 lines
9.9 KiB
Python

"""
Grid engine — gioco "Grid Traders" (sessione 3), regola: STRATEGIA_GRIGLIA.md.
100 agenti ciechi ricevono due serie anonime (X=A=BTC, Y=B=ETH, mai rivelate) e
propongono la CONFIGURAZIONE di una griglia di trading secondo la spec del
documento STRATEGIA_GRIGLIA.md. Questo motore la backtesta in modo
deterministico, causale e fee-aware:
- griglia GEOMETRICA dentro un range definito al deploy su close[i] (§3.2):
ratio = (RANGE_HIGH/RANGE_LOW)^(1/GRID_LEVELS), livello[k] = RL * ratio^k
Il range e' parametrizzato in PERCENTUALE attorno al prezzo di deploy
(range_down/range_up), cosi' la griglia e' backtestabile su tutta la storia.
- capitale suddiviso in anticipo: quote_per_livello = 1/GRID_LEVELS (§3.3)
- VINCOLO BREAK-EVEN (§4): passo > MARGINE(1.5) x costo round-trip.
Se violato il motore SI RIFIUTA DI PARTIRE (come da spec): spec squalificata.
- ciclo (§5.2): compra quote_per_livello su attraversamento VERSO IL BASSO di un
livello non riempito; vendi quel livello su attraversamento VERSO L'ALTO del
livello successivo. Livelli FISSI per tutto l'episodio (non inseguono il prezzo).
- guardie (§5.2/§6): STOP-LOSS sotto RANGE_LOW e TAKE-PROFIT sopra RANGE_HIGH
hanno priorita' su tutto: liquidano l'intera posizione e fermano la griglia.
- episodi: quando una griglia muore (SL / TP / max_bars) se ne deploya una nuova
sul prezzo corrente (il "riavvio del bot" di §6.6, qui automatizzato).
Causalita': il deploy a close[i] usa solo close[i]; i fill avvengono dalle barre
successive lungo il percorso intrabar O->L->H->C (se close>=open) o O->H->L->C.
Fee 0.10% round-trip per livello (baseline Deribit del progetto) + slippage
opzionale per lato (GAME_SLIP), come negli altri giochi.
"""
from __future__ import annotations
import math
from bisect import bisect_left, bisect_right
import numpy as np
from scripts.games.engine import load_anon, splits3, TF_BPM, FEE_RT
MIN_TRADES_PER_MONTH = 10.0
MARGIN = 1.5 # margine di sicurezza del vincolo break-even (§4)
_SLIP = 0.0 # slippage per LATO (oltre alle fee), come engine.py
def set_slippage(slip_per_side: float):
global _SLIP
_SLIP = float(slip_per_side)
def cost_rt(fee: float = FEE_RT) -> float:
"""Costo di un round-trip completo (fee RT + 2 lati di slippage)."""
return fee + 2 * _SLIP
def grid_ratio(p) -> float:
"""Ratio geometrico della griglia: indipendente dal prezzo di deploy."""
rd, ru, L = float(p["range_down"]), float(p["range_up"]), int(p["levels"])
return ((1.0 + ru) / (1.0 - rd)) ** (1.0 / L)
def max_levels(range_down: float, range_up: float, fee: float = FEE_RT) -> int:
"""Massimo numero di livelli che rispetta il vincolo break-even (§4)."""
width = math.log((1.0 + range_up) / (1.0 - range_down))
min_step = math.log(1.0 + MARGIN * cost_rt(fee))
return max(0, int(math.floor(width / min_step)))
# --------------------------------------------------------------------------
# Backtest della griglia (episodi deploy -> SL/TP/timeout -> redeploy)
# --------------------------------------------------------------------------
def _backtest_grid(o, p, fee=FEE_RT):
"""Ritorna l'array dei net-return per trade (round-trip o liquidazione),
in frazione del capitale dell'episodio. None se il vincolo break-even
e' violato (il bot si rifiuta di partire, §4)."""
op, hi, lo, cl = o["open"], o["high"], o["low"], o["close"]
n = len(cl)
crt = cost_rt(fee)
L = int(p["levels"])
rd, ru = float(p["range_down"]), float(p["range_up"])
slb, tpb = float(p["sl_buf"]), float(p["tp_buf"])
max_bars = max(1, int(p["max_bars"]))
if L < 2:
return None
ratio = grid_ratio(p)
step = ratio - 1.0
if step <= MARGIN * crt:
return None # §4: vincolo break-even violato
lstep = math.log(ratio)
with np.errstate(divide="ignore"):
llo = np.log(lo)
lhi = np.log(hi)
qpl = 1.0 / L
rets = []
i = 20 # warmup minimo (parita' con engine.py)
while i < n - 1:
px = float(cl[i])
if not np.isfinite(px) or px <= 0:
i += 1
continue
rl_ = px * (1.0 - rd)
lv = [rl_ * ratio ** k for k in range(L + 1)] # lv[L] = RANGE_HIGH
sl = rl_ * (1.0 - slb)
tp = lv[L] * (1.0 + tpb)
off = math.log(rl_)
end = min(n - 1, i + max_bars)
# indice-cella (floor) di low/high per il fast-skip delle barre quiete
klo = np.floor((llo[i + 1:end + 1] - off) / lstep).astype(np.int64)
khi = np.floor((lhi[i + 1:end + 1] - off) / lstep).astype(np.int64)
slhit = lo[i + 1:end + 1] <= sl
tphit = hi[i + 1:end + 1] >= tp
filled = [False] * L
n_open = 0
cur = px
kc = bisect_right(lv, cur) - 1
done = False
exit_i = end
for j in range(i + 1, end + 1):
jj = j - (i + 1)
if klo[jj] == khi[jj] == kc and not slhit[jj] and not tphit[jj]:
cur = cl[j] # barra quieta: nessun livello toccato
continue
pts = (op[j], lo[j], hi[j], cl[j]) if cl[j] >= op[j] \
else (op[j], hi[j], lo[j], cl[j])
for q in pts:
q = float(q)
if q == cur:
continue
if q < cur:
# discesa: fill dei buy-level attraversati (alto -> basso)
k1 = bisect_left(lv, q) # primo livello >= q
k2 = bisect_left(lv, cur) - 1 # ultimo livello < cur
for k in range(min(k2, L - 1), max(k1, 0) - 1, -1):
if not filled[k]:
filled[k] = True
n_open += 1
if q <= sl:
# STOP-LOSS: vendi tutta la posizione a sl, ferma la griglia
if n_open:
rets.append(sum(
qpl * (sl / lv[k] - 1.0 - crt)
for k in range(L) if filled[k]))
done = True
cur = q
break
else:
# salita: vendi i livelli riempiti il cui target e' attraversato
m1 = bisect_right(lv, cur) # primo livello > cur
m2 = bisect_right(lv, q) - 1 # ultimo livello <= q
for m in range(max(m1, 1), min(m2, L) + 1):
k = m - 1
if filled[k]:
rets.append(qpl * (lv[m] / lv[k] - 1.0 - crt))
filled[k] = False
n_open -= 1
if q >= tp:
# TAKE-PROFIT: chiudi il residuo a tp, ferma la griglia
if n_open:
rets.append(sum(
qpl * (tp / lv[k] - 1.0 - crt)
for k in range(L) if filled[k]))
done = True
cur = q
break
cur = q
if done:
exit_i = j
break
kc = bisect_right(lv, cur) - 1
if not done:
# timeout max_bars: liquida il residuo al close dell'ultima barra
if n_open:
rets.append(sum(
qpl * (cl[end] / lv[k] - 1.0 - crt)
for k in range(L) if filled[k]))
exit_i = end
i = exit_i # redeploy sul prezzo dove e' morta la griglia
return np.array(rets) if rets else np.array([])
# --------------------------------------------------------------------------
# Valutazione + scoring (stessa fitness degli altri giochi)
# --------------------------------------------------------------------------
def evaluate(data, spec, sl=None, fee=FEE_RT):
"""spec = {series: 'A'|'B', tf, params{range_down,range_up,levels,sl_buf,
tp_buf,max_bars}}. Ritorna dict metriche (fitness = pnl + 50*win)."""
series = spec.get("series", "A")
p = spec["params"]
o = data[series]
if sl is not None:
s, e = sl
o = {k: v[s:e] for k, v in o.items()}
rets = _backtest_grid(o, p, fee)
nbars = len(o["close"])
months = nbars / data.get("bpm", TF_BPM["1h"])
if rets is None:
# il bot si rifiuta di partire (vincolo break-even §4)
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, refused=True, fitness=-2e6)
n_tr = len(rets)
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, refused=False, fitness=-1e6)
win_rate = float(np.mean(rets > 0))
pnl = float(np.sum(rets)) * 100
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 + 50.0 * win_rate
if not qualified:
fitness = -1e6 + pnl
return dict(n_trades=n_tr, win_rate=win_rate, pnl_pct=pnl, tpm=tpm,
sharpe=sharpe, avg_ret=avg, qualified=qualified, refused=False,
fitness=fitness)
if __name__ == "__main__":
import time
data = load_anon("1h")
print("loaded", data["n"], "bars,", data["dt"][0], "->", data["dt"][-1])
tr, va, te = splits3(data)
demo = {"series": "B", "tf": "1h",
"params": {"range_down": 0.10, "range_up": 0.10, "levels": 12,
"sl_buf": 0.05, "tp_buf": 0.05, "max_bars": 1000}}
t0 = time.time()
print("TRAIN", evaluate(data, demo, tr))
print("VALID", evaluate(data, demo, va))
print("TEST ", evaluate(data, demo, te))
print("FULL ", evaluate(data, demo, None))
print(f"4 eval in {time.time()-t0:.2f}s")