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>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 09:49:17 +00:00
parent 8adf388e86
commit 8d69a0cef5
16 changed files with 2193 additions and 5 deletions
+322
View File
@@ -0,0 +1,322 @@
"""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):
"""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.
"""
df = _load(asset, tf)
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"]).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:
# 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()
+92
View File
@@ -0,0 +1,92 @@
"""GATE: aggiungere ETH/BTC 30m (vincitore gioco sessione 2) AL BLEND attuale (1h+15m)?
Domanda: il 30m e' un 3o timeframe utile dello spread ETH/BTC, o e' ridondante col 15m
adiacente gia' deployato? Test (engine pairs_sim_flat, == worker):
[1] correlazioni: 30m vs 1h, 30m vs 15m (se ~1 col 15m -> ridondante).
[2] gate PORT06: baseline ATTUALE (6 pairs, incl 15m) vs +30m (7 pairs), mezza size.
[3] robustezza ai costi (fee sweep) del 30m.
uv run python scripts/analysis/pairs30m_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
from scripts.analysis.pairs_research import pairs_sim, pairs_sim_flat
from scripts.analysis.report_families import daily_from
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
WIN_30M = dict(n=53, z_in=1.947, z_exit=1.0, max_bars=24) # vincitore gioco sess.2
def daily(cfg, tf, flat_skip, pos=0.15):
if flat_skip:
r = pairs_sim_flat("ETH", "BTC", tf=tf, **cfg, flat_skip=True, pos=pos)
else:
r = pairs_sim("ETH", "BTC", tf=tf, **cfg, pos=pos)
return daily_from(r["eq_ts"], r["eq_v"]), r
def port_metrics(members, ids, clusters, caps):
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector("cap", ids, dr, caps=caps, clusters=clusters)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT), w
def main():
p = PORTFOLIOS["PORT06"]
eq_base = dict(all_sleeve_equities()) # include gia' PR_ETHBTC (1h) e PR_ETHBTC_15M
e1h = eq_base["PR_ETHBTC"]
e15 = eq_base["PR_ETHBTC_15M"]
e30h, r30 = daily(WIN_30M, "30m", flat_skip=True, pos=0.075) # half size come il 15m
print("=" * 92)
print(" GATE — ETH/BTC 30m (vincitore gioco sess.2) sopra il BLEND 1h+15m attuale")
print(f" 30m: {r30['trades']} trade, {r30.get('n_skip_entry',0)} ingressi flat saltati")
print("=" * 92)
def corr(a, b): return a.pct_change().fillna(0).corr(b.pct_change().fillna(0))
print("\n[1] CORRELAZIONI (rendimenti giornalieri):")
print(f" 30m vs 1h : {corr(e30h, e1h):.3f}")
print(f" 30m vs 15m: {corr(e30h, e15):.3f} <-- se alta, ridondante col 15m gia' deployato")
print(f" (rif) 15m vs 1h: {corr(e15, e1h):.3f}")
print(f"\n[2] GATE PORT06 (cap PAIRS 0.33 + SHAPE 0.0588) | OOS da {OOS_DATE}:")
ids0 = list(p.sleeve_ids)
cl0 = p.clusters
caps = p.caps
f0, o0, _ = port_metrics(eq_base, ids0, cl0, caps)
# + 30m
mem1 = dict(eq_base); mem1["PR_ETHBTC_30M"] = e30h
ids1 = ids0 + ["PR_ETHBTC_30M"]
cl1 = dict(cl0); cl1["PR_ETHBTC_30M"] = "ETH-rev"
f1, o1, w1 = port_metrics(mem1, ids1, cl1, caps)
print(f" {'config':<22}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
print(f" {'ATTUALE (1h+15m)':<22}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
print(f" {'+30m (1h+15m+30m)':<22}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}")
ok = o1["sharpe"] >= o0["sharpe"] - 0.02 and o1["dd"] <= o0["dd"] + 1e-9 \
and f1["sharpe"] >= f0["sharpe"] - 0.02 and f1["dd"] <= f0["dd"] + 1e-9
print(f" => {'MIGLIORA (promosso)' if ok else 'NON migliora (bocciato)'}")
print(f" peso pairs ETH/BTC: 1h {w1.get('PR_ETHBTC',0)*100:.1f}% + 15m "
f"{w1.get('PR_ETHBTC_15M',0)*100:.1f}% + 30m {w1.get('PR_ETHBTC_30M',0)*100:.1f}% "
f"= {(w1.get('PR_ETHBTC',0)+w1.get('PR_ETHBTC_15M',0)+w1.get('PR_ETHBTC_30M',0))*100:.1f}% su 7 coppie")
print("\n[3] ROBUSTEZZA AI COSTI (30m standalone, Sharpe per fee RT/coppia):")
for fee, lbl in [(0.001, "0.20% 1x"), (0.002, "0.40% 2x"), (0.003, "0.60% 3x"),
(0.004, "0.80% 4x"), (0.006, "1.20% 6x")]:
r = pairs_sim_flat("ETH", "BTC", tf="30m", **WIN_30M, flat_skip=True, fee_rt=fee)
print(f" {lbl:<10} Sharpe {r['sharpe']:>6.2f}")
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
"""Flatten one-shot del conto Deribit testnet per il RESET del portafoglio (2026-06-10).
Cancella gli ordini resting noti (TP limit / disaster-SL dai status.json dei worker),
poi chiude TUTTE le posizioni USDC con market reduce-only (close_position di cerbero
non supporta i lineari USDC) e verifica che il conto sia flat.
Uso: uv run python scripts/analysis/reset_flatten.py
"""
import glob
import json
import time
from src.live.cerbero_client import CerberoClient
from src.live.execution import ExecutionClient, contract_spec, register_contract
def main():
c = CerberoClient()
ex = ExecutionClient(client=c)
# 1) ordini resting noti dai status.json (TP limit + disaster bracket)
oids = set()
for f in glob.glob("data/portfolio_paper/*/status.json"):
s = json.load(open(f))
for k in ("real_tp_order_id", "real_dsl_order_id"):
if s.get(k):
oids.add(s[k])
for oid in sorted(oids):
r = ex.cancel_order(oid)
print(f"cancel {oid}: {r.get('state', r)}")
# 2) flatten posizioni USDC (market reduce-only, max 5 passate)
for attempt in range(5):
live = [p for p in c.get_positions("USDC")
if abs(float(p.get("size", 0) or 0)) > 0]
if not live:
print("FLAT — conto pulito")
return True
for p in live:
inst = p["instrument"]
register_contract(inst, c)
size_usd = abs(float(p["size"]))
mark = float(p.get("mark_price") or 0)
step = contract_spec(inst)["step"]
entry_side = "buy" if p["direction"] == "long" else "sell"
units = size_usd / mark if mark else 0
amount = round(units / step) * step
if amount <= 0:
print(f" {inst}: residuo {size_usd:.2f} USD sotto mezzo step, ok")
continue
f = ex.close_amount(inst, entry_side, amount, label="reset_flatten")
print(f" close {inst} amt={amount} -> {f.order_state} fill={f.fill_price} "
f"verified={f.verified} {f.notes[:60]}")
time.sleep(3)
left = [(p["instrument"], round(float(p["size"]), 4))
for p in c.get_positions("USDC") if abs(float(p.get("size", 0) or 0)) > 0]
print("RESIDUO FINALE:", left or "FLAT")
return not left
if __name__ == "__main__":
ok = main()
raise SystemExit(0 if ok else 1)
+26 -5
View File
@@ -46,6 +46,19 @@ SINGLE_FAMILIES = ["zscore", "breakout", "ma_cross", "rsi", "momentum"]
DIRECTIONS = ["reversion", "trend"]
TIMEFRAMES = ["5m", "15m", "30m", "1h", "2h", "4h", "1d"] # tutti i timing validi
# Vincolo opzionale: accetta SOLO strategie NON gia' usate in live. Firme live (da
# vietare): 'pairs' (PR01) + REVERSION di zscore(MR01)/breakout(MR02)/momentum(MR07,
# return-reversal). NB: momentum+reversion == MR07 -> e' LIVE, va vietato (loophole).
# Coercizione: pairs -> ma_cross(trend); (zscore|breakout|momentum)+reversion -> +trend.
# Resta spazio NUOVO: trend di zscore/breakout/momentum, ma_cross, rsi (ogni direzione).
NO_LIVE = False
_LIVE_REV_FAMS = {"zscore", "breakout", "momentum"} # in reversion = MR01/MR02/MR07 live
def set_no_live(v: bool):
global NO_LIVE
NO_LIVE = bool(v)
def _rand_param(rng, lo, hi, typ):
if typ == "i":
@@ -54,7 +67,7 @@ def _rand_param(rng, lo, hi, typ):
def random_spec(rng):
if rng.random() < 0.25:
if not NO_LIVE and rng.random() < 0.25:
fam = "pairs"
else:
fam = rng.choice(SINGLE_FAMILIES)
@@ -66,7 +79,10 @@ def random_spec(rng):
spec["series"] = "AB"
else:
spec["series"] = rng.choice(["A", "B"])
spec["params"]["direction"] = rng.choice(DIRECTIONS)
d = rng.choice(DIRECTIONS)
if NO_LIVE and fam in _LIVE_REV_FAMS:
d = "trend" # zscore/breakout in reversion sono live -> trend
spec["params"]["direction"] = d
return spec
@@ -98,6 +114,8 @@ def _normalize(spec):
fam = spec.get("family")
if fam not in SPACE:
fam = "zscore"
if NO_LIVE and fam == "pairs":
fam = "ma_cross" # pairs (PR01) e' live -> rimpiazza con ma_cross (nuovo, trend)
out = {"family": fam, "params": {}}
for k, (lo, hi, typ) in SPACE[fam].items():
v = spec.get("params", {}).get(k, (lo + hi) / 2)
@@ -113,7 +131,10 @@ def _normalize(spec):
else:
out["series"] = spec.get("series", "A") if spec.get("series") in ("A", "B") else "A"
d = spec.get("params", {}).get("direction") or spec.get("direction")
out["params"]["direction"] = d if d in DIRECTIONS else "reversion"
d = d if d in DIRECTIONS else "reversion"
if NO_LIVE and fam in _LIVE_REV_FAMS and d == "reversion":
d = "trend" # zscore/breakout in reversion = fade live -> trend
out["params"]["direction"] = d
return out
@@ -165,12 +186,12 @@ def run_tournament(specs, briefs=None, seed=7,
for ep in range(1, epochs + 1):
# elaborazione: l'agente affina sul TRAIN (cio' che vede); ricalcola VALID
for a in alive():
cand = mutate(a.spec, rng)
cand = _normalize(mutate(a.spec, rng)) # normalizza PRIMA di valutare
data = datasets[a.tf]
tr, va, _ = splits_map[a.tf]
m = evaluate(data, cand, tr)
if m["fitness"] > a.train_fit:
a.spec = _normalize(cand)
a.spec = cand
a.metrics, a.train_fit = m, m["fitness"]
a.vmetrics = evaluate(data, a.spec, va)
a.valid_fit = a.vmetrics["fitness"]
+251
View File
@@ -0,0 +1,251 @@
"""
Arena del gioco GRID TRADERS (sessione 3): 100 agenti ciechi configurano una
griglia di trading secondo STRATEGIA_GRIGLIA.md su due serie anonime
(A=BTC, B=ETH, mai rivelate; gli agenti le vedono come X/Y). Torneo standard:
3 finestre TRAIN/VALID/TEST, 90 epoche di hill-climb sul TRAIN, ogni 10 epoche
l'orchestratore blocca il 10% meno profittevole in VALID, fino a 10 superstiti.
TEST = OOS puro mai toccato dall'ottimizzazione.
uv run python -m scripts.games.grid_arena # 100 random (smoke)
GAME_SPECS_DIR=... GAME_OUT=... uv run python -m scripts.games.grid_arena --from-specs
"""
from __future__ import annotations
import json
import os
import random
import sys
from pathlib import Path
from scripts.games.engine import load_anon, splits3
from scripts.games import grid_engine
from scripts.games.grid_engine import evaluate, max_levels
OUT = Path("data/games")
OUT.mkdir(parents=True, exist_ok=True)
# Spazio parametri (min, max, tipo). range_*/sl_buf/tp_buf in FRAZIONE.
PSPACE = dict(
range_down=(0.02, 0.30, "f"),
range_up=(0.02, 0.30, "f"),
levels=(4, 30, "i"),
sl_buf=(0.01, 0.15, "f"),
tp_buf=(0.01, 0.15, "f"),
max_bars=(48, 3000, "i"),
)
SERIES = ["A", "B"]
TIMEFRAMES = ["15m", "30m", "1h", "2h", "4h", "1d"] # no 5m (costo computazionale)
def _rand(rng, lo, hi, typ):
return int(rng.randint(int(lo), int(hi))) if typ == "i" else round(rng.uniform(lo, hi), 4)
def random_spec(rng):
p = {k: _rand(rng, *v) for k, v in PSPACE.items()}
return {"series": rng.choice(SERIES), "tf": rng.choice(TIMEFRAMES), "params": p}
def _normalize(spec):
"""Clampa la spec nello spazio valido e APPLICA il vincolo break-even (§4):
se il passo e' troppo fitto riduce GRID_LEVELS (come da spec: 'vanno ridotti
i GRID_LEVELS o allargato il range')."""
out = {"series": spec.get("series") if spec.get("series") in SERIES else "A",
"tf": spec.get("tf") if spec.get("tf") in TIMEFRAMES else "1h",
"params": {}}
src = spec.get("params", spec)
for k, (lo, hi, typ) in PSPACE.items():
v = src.get(k, (lo + hi) / 2)
try:
v = float(v)
except Exception:
v = (lo + hi) / 2
v = max(lo, min(hi, v))
out["params"][k] = int(round(v)) if typ == "i" else round(v, 4)
p = out["params"]
lmax = max_levels(p["range_down"], p["range_up"])
if lmax >= 2:
p["levels"] = min(p["levels"], lmax)
return out
def mutate(spec, rng, strength=0.25):
"""Hill-climb: perturba 1-2 parametri; raramente cambia serie.
Il timeframe e' l'identita' dell'agente -> non muta."""
s = json.loads(json.dumps(spec))
for k in rng.sample(list(PSPACE), k=rng.randint(1, 2)):
lo, hi, typ = PSPACE[k]
span = (hi - lo) * strength
nv = max(lo, min(hi, s["params"][k] + rng.uniform(-span, span)))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 4)
if rng.random() < 0.05:
s["series"] = rng.choice(SERIES)
return s
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid
self.spec = _normalize(spec)
self.brief = brief
self.train_fit = -1e9
self.valid_fit = -1e9
self.metrics = {}
self.vmetrics = {}
self.alive = True
self.culled_epoch = None
@property
def tf(self):
return self.spec["tf"]
def score(self, datasets, sm):
data = datasets[self.tf]
tr, va, _ = sm[self.tf]
self.metrics = evaluate(data, self.spec, tr)
self.vmetrics = evaluate(data, self.spec, va)
self.train_fit = self.metrics["fitness"]
self.valid_fit = self.vmetrics["fitness"]
def run_tournament(specs, briefs=None, seed=2026, epochs=90, cull_every=10,
cull_n=10, out_name="grid_result.json", log=print):
rng = random.Random(seed)
used_tfs = sorted({_normalize(s)["tf"] for s in specs})
datasets = {tf: load_anon(tf) for tf in used_tfs}
sm = {tf: splits3(datasets[tf], 0.60, 0.20) for tf in used_tfs}
briefs = briefs or [""] * len(specs)
agents = [Agent(i, s, briefs[i] if i < len(briefs) else "")
for i, s in enumerate(specs)]
for a in agents:
a.score(datasets, sm)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} agenti | best VALID "
f"{max(a.valid_fit for a in agents):.1f}")
history = []
for ep in range(1, epochs + 1):
for a in alive():
cand = _normalize(mutate(a.spec, rng))
data = datasets[a.tf]
tr, va, _ = sm[a.tf]
m = evaluate(data, cand, tr)
if m["fitness"] > a.train_fit:
a.spec, a.metrics, a.train_fit = cand, m, m["fitness"]
a.vmetrics = evaluate(data, a.spec, va)
a.valid_fit = a.vmetrics["fitness"]
if ep % cull_every == 0:
av = sorted(alive(), key=lambda a: a.valid_fit)
k = cull_n if len(av) - cull_n >= 10 else max(0, len(av) - 10)
for a in av[:k]:
a.alive = False
a.culled_epoch = ep
log(f"[epoch {ep:2d}] cull {k:2d} -> {len(alive()):3d} vivi | "
f"best VALID {max(a.valid_fit for a in alive()):.1f} | "
f"worst-alive {min(a.valid_fit for a in alive()):.1f}")
history.append({"epoch": ep, "alive": len(alive()),
"best_valid": max(a.valid_fit for a in alive())})
survivors = sorted(alive(), key=lambda a: a.valid_fit, reverse=True)
results = []
for rank, a in enumerate(survivors, 1):
data = datasets[a.tf]
_, _, te = sm[a.tf]
results.append({"rank": rank, "agent": a.id, "spec": a.spec,
"brief": a.brief, "tf": a.tf,
"train": a.metrics, "valid": a.vmetrics,
"test": evaluate(data, a.spec, te),
"full": evaluate(data, a.spec, None)})
payload = {"n_agents": len(specs), "epochs": epochs,
"survivors": len(survivors), "results": results,
"history": history, "game": "grid",
"rule": "STRATEGIA_GRIGLIA.md",
"reveal": {"A": "BTC", "B": "ETH"}}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n========== CLASSIFICA GRID TRADERS (top %d) ==========" % top)
log("VALID = finestra del giudice | TEST = OOS puro (mai ottimizzato)")
log(f"{'#':>2} {'ag':>4} {'tf':>4} {'ser':>3} {'rng-/+':>11} {'lvl':>3} "
f"{'sl/tp buf':>11} {'mbars':>5} {'TEpnl%':>7} {'TEwin':>5} "
f"{'TEtpm':>6} {'TEsh':>5} {'VApnl%':>7}")
for r in payload["results"][:top]:
sp = r["spec"]; p = sp["params"]; te = r["test"]; va = r["valid"]
log(f"{r['rank']:>2} {r['agent']:>4} {sp['tf']:>4} {sp['series']:>3} "
f"{p['range_down']*100:>4.1f}/{p['range_up']*100:>4.1f}% {p['levels']:>3} "
f"{p['sl_buf']*100:>4.1f}/{p['tp_buf']*100:>4.1f}% {p['max_bars']:>5} "
f"{te['pnl_pct']:>7.0f} {te['win_rate']*100:>4.0f}% {te['tpm']:>6.1f} "
f"{te['sharpe']:>5.1f} {va['pnl_pct']:>7.0f}")
def load_specs(specs_dir, n=100):
"""Carica le spec proposte dagli agenti ciechi (X->A, Y->B, pct->frazione)."""
rng = random.Random(7)
specs, briefs = [], []
for i in range(n):
f = Path(specs_dir) / f"agent_{i}.json"
spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
src = raw.get("params", raw)
params = {
"range_down": float(src.get("range_down_pct", src.get("range_down", 10))) ,
"range_up": float(src.get("range_up_pct", src.get("range_up", 10))),
"levels": src.get("grid_levels", src.get("levels", 10)),
"sl_buf": float(src.get("sl_buf_pct", src.get("sl_buf", 5))),
"tp_buf": float(src.get("tp_buf_pct", src.get("tp_buf", 5))),
"max_bars": src.get("max_bars", 500),
}
# gli agenti parlano in percentuale -> frazione
for k in ("range_down", "range_up", "sl_buf", "tp_buf"):
if params[k] > 1.0:
params[k] = params[k] / 100.0
spec = _normalize({
"series": {"X": "A", "Y": "B"}.get(raw.get("series"), raw.get("series")),
"tf": raw.get("tf", "1h"), "params": params})
briefs.append(str(raw.get("hypothesis", ""))[:300])
except Exception:
spec = None
if spec is None:
spec = random_spec(rng)
briefs.append("(spec mancante -> sostituto casuale)")
specs.append(spec)
n_real = sum(1 for b in briefs if "mancante" not in b)
print(f"caricati {n_real}/{n} spec da agenti reali, {n - n_real} sostituiti casuali")
return specs, briefs
def main():
slip = float(os.environ.get("GAME_SLIP", "0.0"))
grid_engine.set_slippage(slip)
if slip > 0:
print(f"SLIPPAGE attivo: {slip*100:.3f}%/lato")
epochs = int(os.environ.get("GAME_EPOCHS", "90"))
if "--from-specs" in sys.argv:
sd = os.environ.get("GAME_SPECS_DIR", "data/games/specs_grid")
on = os.environ.get("GAME_OUT", "grid_result.json")
specs, briefs = load_specs(sd)
payload = run_tournament(specs, briefs=briefs, epochs=epochs, out_name=on)
else:
rng = random.Random(42)
payload = run_tournament([random_spec(rng) for _ in range(100)],
seed=42, epochs=epochs)
leaderboard(payload)
rev = payload["reveal"]
w = payload["results"][0]
sp = w["spec"]; p = sp["params"]
print(f"\n>>> RIVELAZIONE: Serie X = {rev['A']}, Serie Y = {rev['B']}. "
f"Gli agenti non lo sapevano. <<<")
print(f"\nVINCITORE: agente #{w['agent']} su {sp['tf']} serie {sp['series']} | "
f"griglia -{p['range_down']*100:.1f}%/+{p['range_up']*100:.1f}% "
f"x{p['levels']} livelli, SL buf {p['sl_buf']*100:.1f}%, "
f"TP buf {p['tp_buf']*100:.1f}%, max {p['max_bars']} barre")
print(f" ipotesi dell'agente: {w['brief']}")
print(f" TEST(OOS): PnL {w['test']['pnl_pct']:.0f}% | win "
f"{w['test']['win_rate']*100:.0f}% | {w['test']['tpm']:.1f} trade/mese | "
f"Sharpe {w['test']['sharpe']:.1f}")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
"""
grid_brief — digest ANONIMO per gli agenti del gioco GRID TRADERS (sessione 3).
Come agent_brief, ma con statistiche pensate per DIMENSIONARE una griglia:
oltre a vol/autocorrelazioni, l'escursione tipica (max/min - 1) su finestre
rolling e quanto spesso il prezzo "esce" da un range simmetrico attorno a un
punto di partenza entro N barre. L'agente non sa cosa siano X e Y.
uv run python -m scripts.games.grid_brief 1h # stampa il digest
uv run python -m scripts.games.grid_brief --all # scrive data/games/grid_digests.json
"""
from __future__ import annotations
import json
import numpy as np
import pandas as pd
from scripts.games.engine import load_anon
from scripts.games.agent_brief import _stats
TF_ID = {"15m": "T2", "30m": "T3", "1h": "T4", "2h": "T5", "4h": "T6", "1d": "T7"}
def _range_stats(close, windows=(100, 500, 2000)):
"""Escursione (max/min - 1) su finestre rolling: mediana e p90, in %."""
s = pd.Series(close)
out = {}
for w in windows:
if len(close) < w * 2:
continue
exc = (s.rolling(w).max() / s.rolling(w).min() - 1).dropna()
out[f"w{w}"] = {"median_pct": round(float(exc.median() * 100), 2),
"p90_pct": round(float(exc.quantile(0.9) * 100), 2)}
return out
def _escape_stats(close, half_widths=(0.05, 0.10, 0.20), horizon=500):
"""Da un punto di partenza, % di volte in cui il prezzo esce da
+-half_width entro `horizon` barre (campionato ogni horizon/2)."""
n = len(close)
stepi = max(1, horizon // 2)
starts = np.arange(0, n - horizon, stepi)
out = {}
for hw in half_widths:
esc = 0
for st in starts:
w = close[st:st + horizon]
p0 = w[0]
if np.any(w > p0 * (1 + hw)) or np.any(w < p0 * (1 - hw)):
esc += 1
out[f"+-{hw*100:.0f}%"] = round(100.0 * esc / max(1, len(starts)), 1)
return out
def make_grid_digest(tf: str, window: int = 60):
data = load_anon(tf)
n = data["n"]
s = max(0, n - window)
dig = {"timeframe_id": TF_ID.get(tf, "T?"), "n_bars_total": n, "series": {}}
for name in ("A", "B"):
o = data[name]
c = o["close"]
norm = c[s:] / c[s] * 100.0
dig["series"][{"A": "X", "B": "Y"}[name]] = {
"stats": _stats(c, o["high"], o["low"]),
"range_excursion_rolling": _range_stats(c),
"escape_from_range_within_500_bars_pct": _escape_stats(c),
"recent_window_norm": [round(float(v), 2) for v in norm],
}
return dig
GRID_MENU = {
"gioco": ("Configura una GRIGLIA di trading secondo la spec (griglia geometrica "
"FISSA dentro un range attorno al prezzo di deploy; compra quando il "
"prezzo scende attraverso un livello, rivendi quel livello quando "
"risale al livello successivo; stop-loss sotto il range e take-profit "
"sopra chiudono tutto; poi la griglia si ri-deploya sul prezzo corrente)."),
"obiettivo": ("PnL netto positivo dopo i costi (0.10% andata+ritorno per ogni "
"round-trip di livello). Servono >=10 operazioni al mese. La "
"griglia monetizza le oscillazioni e PERDE nei trend: lo stop-loss "
"limita il danno. Non sai cosa siano X e Y."),
"vincolo_break_even": ("passo_griglia = ((1+range_up)/(1-range_down))^(1/grid_levels) - 1 "
"DEVE superare 1.5 x 0.10% = 0.15%, o il bot si rifiuta "
"di partire. Griglie troppo fitte muoiono di fee."),
"parametri": {
"series": "X oppure Y",
"range_down_pct": "estremo inferiore del range, % sotto il prezzo di deploy (2-30)",
"range_up_pct": "estremo superiore del range, % sopra il prezzo di deploy (2-30)",
"grid_levels": "numero di livelli della griglia (4-30)",
"sl_buf_pct": "stop-loss: % sotto RANGE_LOW (1-15)",
"tp_buf_pct": "take-profit: % sopra RANGE_HIGH (1-15)",
"max_bars": "durata massima di una griglia in barre, poi liquida e ri-deploya (48-3000)",
},
"trade_off": ("range stretto + tanti livelli = tanti round-trip piccoli ma SL "
"frequenti nei trend; range largo = SL rari ma capitale spesso "
"fermo. Lo stop-loss largo aumenta la perdita quando scatta; "
"stretto scatta piu' spesso. Usa le statistiche di escursione "
"del digest per dimensionare range e stop."),
"output_schema": {
"series": "X|Y", "range_down_pct": "num", "range_up_pct": "num",
"grid_levels": "int", "sl_buf_pct": "num", "tp_buf_pct": "num",
"max_bars": "int", "hypothesis": "1-2 frasi: il tuo ragionamento",
},
}
if __name__ == "__main__":
import sys
from pathlib import Path
if "--all" in sys.argv:
out = {tf: make_grid_digest(tf) for tf in TF_ID}
p = Path("data/games/grid_digests.json")
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(out))
print(f"scritti digest per {list(out)} -> {p}")
else:
tf = sys.argv[1] if len(sys.argv) > 1 else "1h"
print(json.dumps(make_grid_digest(tf), indent=2)[:3000])
+231
View File
@@ -0,0 +1,231 @@
"""
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")
+80
View File
@@ -0,0 +1,80 @@
"""Calibra una superficie premi REALE dalla catena cerbero-bite -> data/games/opt_calib_*.json.
Per ETH e BTC, dalla chain reale (OptionChain): premio mediano (ask, %spot), spread
bid/ask mediano, e IV mediana per (moneyness OTM x tenor). Piu' DVOL medio della finestra
(per scalare i premi sulla storia). + gate liquidita': max OTM con bid>0 frequente.
Cosi' il motore del gioco prezza con NUMERI REALI invece del Black-Scholes sintetico.
uv run python -m scripts.games.opt_calibrate
"""
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.options_chain import OptionChain
OUT = PROJECT_ROOT / "data" / "games"
# griglie: OTM firmato (put<0, call>0) e tenor in giorni
OTM_GRID = [-0.25, -0.20, -0.15, -0.10, -0.07, -0.05, -0.03, 0.0,
0.03, 0.05, 0.07, 0.10, 0.15, 0.20, 0.25]
TEN_GRID = [7, 14, 21, 30, 45]
def calibrate(asset: str):
oc = OptionChain(asset)
d = oc.df.copy()
spot = oc._spot_proxy()
d["spot"] = d["timestamp"].map(spot)
d = d.dropna(subset=["spot", "ask", "bid", "iv"])
d = d[d["ask"] > 0]
d["otm"] = d["strike"] / d["spot"] - 1.0 # firmato: <0 put OTM, >0 call OTM
d["prem_pct"] = d["ask"] * 100.0 # ask in coin -> %notional
d["spread"] = (d["ask"] - d["bid"]) / ((d["ask"] + d["bid"]) / 2)
d["sellable"] = (d["bid"] > 0).astype(float)
# superficie: per ciascun (tipo, otm_bin, tenor_bin) -> mediane
surf = {"P": {}, "C": {}}
for typ in ("P", "C"):
dt = d[d["option_type"] == typ]
for ten in TEN_GRID:
tlo, thi = ten * 0.6, ten * 1.6
dtt = dt[(dt["tenor_d"] >= tlo) & (dt["tenor_d"] <= thi)]
for otm in OTM_GRID:
# banda moneyness +-1.5% attorno al target
band = dtt[(dtt["otm"] >= otm - 0.02) & (dtt["otm"] <= otm + 0.02)]
if len(band) < 5:
continue
surf[typ][f"{otm:+.2f}|{ten}"] = dict(
prem=round(float(band["prem_pct"].median()), 4),
spread=round(float(band["spread"].median()), 4),
iv=round(float(band["iv"].median()), 4),
sellable=round(float(band["sellable"].mean()), 3),
n=int(len(band)))
dvol_avg = float(np.nanmedian(d["iv"][d["otm"].abs() < 0.03])) # ~ATM IV medio
# gate liquidita': OTM piu' profondo (put) con bid>0 nel >=50% dei casi
puts = d[d["option_type"] == "P"]
deep = puts[puts["otm"] <= -0.10]
out = {"asset": asset, "dvol_chain": round(dvol_avg, 4),
"surface": surf, "otm_grid": OTM_GRID, "ten_grid": TEN_GRID,
"window": [str(oc.df["ts"].min())[:10], str(oc.df["ts"].max())[:10]]}
(OUT / f"opt_calib_{asset.lower()}.json").write_text(json.dumps(out))
npts = len(surf["P"]) + len(surf["C"])
print(f"{asset}: {npts} punti superficie | ATM IV ~{dvol_avg:.2f} | finestra {out['window']}")
# stampa qualche premio reale put per sanity
for key in ["-0.05|14", "-0.10|14", "-0.15|30", "-0.20|45"]:
v = surf["P"].get(key)
if v:
print(f" put {key:>9}: prem {v['prem']:.2f}% spread {v['spread']*100:.0f}% "
f"iv {v['iv']:.0f}% sellable {v['sellable']*100:.0f}% (n={v['n']})")
if __name__ == "__main__":
for a in ("BTC", "ETH"):
calibrate(a)
+190
View File
@@ -0,0 +1,190 @@
"""
Arena del gioco-OPZIONI: 100 agenti ciechi propongono STRUTTURE in opzioni su due
serie anonime (A=BTC, B=ETH). Torneo identico al gioco-prezzi (3 finestre TRAIN/VALID/
TEST, 90 epoche, cull 10% ogni 10 epoche -> 10 finalisti), ma le strategie sono opzioni
prezzate con BS + skew + DVOL (scripts/games/options_engine.py).
uv run python -m scripts.games.options_arena # 100 agenti random (test)
GAME_SPECS_DIR=... GAME_OUT=... uv run python -m scripts.games.options_arena --from-specs
"""
from __future__ import annotations
import json
import os
import random
import sys
from pathlib import Path
import numpy as np
from scripts.games.options_engine import (load_opt, splits3, evaluate, STRUCTURES)
OUT = Path("data/games"); OUT.mkdir(parents=True, exist_ok=True)
# spazio parametri: (min, max, tipo)
PSPACE = dict(otm=(0.02, 0.20, "f"), width=(0.02, 0.12, "f"), dte=(7, 45, "i"))
SERIES = ["A", "B"]
def _rand(rng, lo, hi, typ):
return int(rng.randint(int(lo), int(hi))) if typ == "i" else round(rng.uniform(lo, hi), 3)
def random_spec(rng):
p = {k: _rand(rng, *v) for k, v in PSPACE.items()}
return {"structure": rng.choice(STRUCTURES), "series": rng.choice(SERIES), "params": p}
def _normalize(spec):
st = spec.get("structure")
if st not in STRUCTURES:
st = "short_put"
out = {"structure": st, "series": spec.get("series") if spec.get("series") in SERIES else "A",
"params": {}}
src = spec.get("params", {})
for k, (lo, hi, typ) in PSPACE.items():
v = src.get(k, (lo + hi) / 2)
try:
v = float(v)
except Exception:
v = (lo + hi) / 2
v = max(lo, min(hi, v))
out["params"][k] = int(round(v)) if typ == "i" else round(v, 3)
# flatten per evaluate (structure/otm/width/dte)
out["structure"] = st
return out
def _flat(spec):
return {"structure": spec["structure"], **spec["params"]}
def mutate(spec, rng, strength=0.25):
s = json.loads(json.dumps(spec))
keys = list(PSPACE)
for k in rng.sample(keys, k=rng.randint(1, 2)):
lo, hi, typ = PSPACE[k]
span = (hi - lo) * strength
nv = max(lo, min(hi, s["params"][k] + rng.uniform(-span, span)))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 3)
if rng.random() < 0.12:
s["structure"] = rng.choice(STRUCTURES)
if rng.random() < 0.05:
s["series"] = rng.choice(SERIES)
return s
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid
self.spec = _normalize(spec)
self.brief = brief
self.train_fit = self.valid_fit = -1e9
self.metrics = self.vmetrics = {}
self.alive = True
@property
def series(self):
return self.spec["series"]
def score(self, datasets, splits_map):
d = datasets[self.series]; tr, va, _ = splits_map[self.series]
self.metrics = evaluate(d, _flat(self.spec), tr)
self.vmetrics = evaluate(d, _flat(self.spec), va)
self.train_fit = self.metrics["fitness"]; self.valid_fit = self.vmetrics["fitness"]
def run_tournament(specs, briefs=None, seed=2026, epochs=90, cull_every=10, cull_n=10,
out_name="options_result.json", log=print):
rng = random.Random(seed)
datasets = {"A": load_opt("BTC"), "B": load_opt("ETH")}
splits_map = {k: splits3(datasets[k]) for k in datasets}
briefs = briefs or [""] * len(specs)
agents = [Agent(i, s, briefs[i] if i < len(briefs) else "") for i, s in enumerate(specs)]
for a in agents:
a.score(datasets, splits_map)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} agenti | best VALID {max(a.valid_fit for a in agents):.1f}")
for ep in range(1, epochs + 1):
for a in alive():
cand = _normalize(mutate(a.spec, rng))
d = datasets[cand["series"]]; tr, va, _ = splits_map[cand["series"]]
m = evaluate(d, _flat(cand), tr)
if m["fitness"] > a.train_fit:
a.spec, a.metrics, a.train_fit = cand, m, m["fitness"]
a.vmetrics = evaluate(d, _flat(cand), va); a.valid_fit = a.vmetrics["fitness"]
if ep % cull_every == 0:
av = sorted(alive(), key=lambda a: a.valid_fit)
k = cull_n if len(av) - cull_n >= 10 else max(0, len(av) - 10)
for a in av[:k]:
a.alive = False
log(f"[epoch {ep:2d}] cull {k:2d} -> {len(alive()):3d} | best VALID "
f"{max(a.valid_fit for a in alive()):.1f} | worst {min(a.valid_fit for a in alive()):.1f}")
survivors = sorted(alive(), key=lambda a: a.valid_fit, reverse=True)
results = []
for rank, a in enumerate(survivors, 1):
d = datasets[a.series]; _, _, te = splits_map[a.series]
results.append({"rank": rank, "agent": a.id, "spec": a.spec, "brief": a.brief,
"series": a.series, "train": a.metrics, "valid": a.vmetrics,
"test": evaluate(d, _flat(a.spec), te), "full": evaluate(d, _flat(a.spec), None)})
payload = {"n_agents": len(specs), "survivors": len(survivors), "results": results,
"reveal": {"A": "BTC", "B": "ETH"}, "game": "options"}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n========= CLASSIFICA FINALE OPZIONI (top %d) =========" % top)
log(f"{'#':>2} {'ag':>4} {'ser':>3} {'struttura':>14} {'otm':>5} {'dte':>4} "
f"{'TEpnl%':>8} {'TEwin':>5} {'TEtpm':>6} {'TEsh':>6}")
for r in payload["results"][:top]:
sp = r["spec"]; te = r["test"]; p = sp["params"]
log(f"{r['rank']:>2} {r['agent']:>4} {sp['series']:>3} {sp['structure']:>14} "
f"{p['otm']:>5.2f} {p['dte']:>4} {te['pnl_pct']:>8.0f} {te['win_rate']*100:>4.0f}% "
f"{te['tpm']:>6.0f} {te['sharpe']:>6.1f}")
def load_specs(specs_dir, n=100):
rng = random.Random(7); specs, briefs = [], []
for i in range(n):
f = Path(specs_dir) / f"agent_{i}.json"
spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
params = {k: raw.get(k, raw.get("params", {}).get(k)) for k in PSPACE}
spec = _normalize({"structure": raw.get("structure"),
"series": {"X": "A", "Y": "B"}.get(raw.get("series"), raw.get("series")),
"params": params})
briefs.append(str(raw.get("hypothesis", ""))[:300])
except Exception:
spec = None
if spec is None:
spec = random_spec(rng); briefs.append("(spec mancante -> random)")
specs.append(spec)
return specs, briefs
def main():
if "--from-specs" in sys.argv:
sd = os.environ.get("GAME_SPECS_DIR", "data/games/specs_opt")
on = os.environ.get("GAME_OUT", "options_result.json")
specs, briefs = load_specs(sd)
n_real = sum(1 for b in briefs if "mancante" not in b)
print(f"caricati {n_real}/100 spec da agenti reali")
payload = run_tournament(specs, briefs=briefs, out_name=on)
else:
rng = random.Random(42)
payload = run_tournament([random_spec(rng) for _ in range(100)], seed=42)
leaderboard(payload)
rev = payload["reveal"]; w = payload["results"][0]
print(f"\n>>> RIVELAZIONE: A={rev['A']}, B={rev['B']}. Gli agenti non lo sapevano. <<<")
print(f"VINCITORE: #{w['agent']} {w['series']} {w['spec']['structure']} "
f"otm{w['spec']['params']['otm']} dte{w['spec']['params']['dte']}")
print(f" ipotesi: {w['brief']}")
print(f" TEST: PnL {w['test']['pnl_pct']:.0f}% | win {w['test']['win_rate']*100:.0f}% | "
f"{w['test']['tpm']:.0f} tr/mese | Sharpe {w['test']['sharpe']:.1f}")
if __name__ == "__main__":
main()
+215
View File
@@ -0,0 +1,215 @@
"""
Motore del gioco-OPZIONI: prezza e backtesta strutture in opzioni proposte dagli
agenti ciechi, sui prezzi REALI ETH/BTC, con Black-Scholes + skew fittato + DVOL storica.
NON usa la chain reale (solo 6 settimane, un regime): prezza sinteticamente con la
vol implicita storica (DVOL Deribit, dal 2021-03) e la curva di skew fittata sulle IV
reali della ricerca credit-spread (iv/atm = 1 - 0.664*k + 3.494*k^2, k=ln(K/S)). Costi:
haircut bid/ask sulle opzioni (il fill reale e' peggiore del mid). Roll giornaliero,
hold-to-expiry (terminale model-free dai prezzi reali). PnL per-trade ADDITIVO.
Caveat onesto (dalla ricerca del progetto): il premium-selling a skew negativo vince nei
campioni calmi e restituisce tutto nei crash -> il gioco lo mostrera'.
"""
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 json as _json
from src.data.downloader import load_data
from scripts.analysis.option_overlay_lab import bs_put, bs_call, dvol_for
# skew fittato (fallback se manca la calibrazione reale): iv/atm in funzione di k=ln(K/S).
SKEW_A, SKEW_B = -0.664, 3.494
MIN_TRADES_PER_MONTH = 10.0
TRADING_DAYS_MONTH = 30.0
# --- pricing REALE: superficie premi/spread da cerbero-bite (scripts/games/opt_calibrate.py) ---
_CALIB_DIR = PROJECT_ROOT / "data" / "games"
_CALIB = {}
def _load_calib(asset):
if asset not in _CALIB:
f = _CALIB_DIR / f"opt_calib_{asset.lower()}.json"
_CALIB[asset] = _json.loads(f.read_text()) if f.exists() else None
return _CALIB[asset]
def _surf_lookup(cal, typ, otm_signed, dte):
"""Premio% e spread reali per (otm firmato, dte): punto di griglia piu' vicino.
Ritorna (prem_pct, spread, sellable) o None se fuori dalla superficie liquida."""
s = cal["surface"][typ]
og = cal["otm_grid"]; tg = cal["ten_grid"]
o = min(og, key=lambda x: abs(x - otm_signed))
t = min(tg, key=lambda x: abs(x - dte))
if abs(o - otm_signed) > 0.06: # troppo lontano dagli strike reali -> illiquido
return None
v = s.get(f"{o:+.2f}|{t}")
if not v or v["sellable"] < 0.5:
return None
return v["prem"], v["spread"], v["sellable"]
def iv_skew(k: float, atm: float) -> float:
"""IV per moneyness k=ln(K/S) dato l'ATM vol. Clamp a [0.3x, 3x] atm."""
mult = 1.0 + SKEW_A * k + SKEW_B * k * k
mult = min(max(mult, 0.3), 3.0)
return atm * mult
def load_opt(asset: str = "ETH"):
"""Prezzi GIORNALIERI (resample 1h->1d) + DVOL allineata. asset reale nascosto."""
df = load_data(asset, "1h").copy()
df["dt"] = pd.to_datetime(df["datetime"])
g = df.set_index("dt").resample("1D").agg(
{"timestamp": "first", "open": "first", "high": "max", "low": "min",
"close": "last"}).dropna(subset=["close"]).reset_index(drop=True)
g["timestamp"] = g["timestamp"].astype("int64")
dv = dvol_for(g, asset)
cal = _load_calib(asset)
dvol_chain = (cal["dvol_chain"] / 100.0) if cal else float(np.nanmedian(dv))
return {"close": g["close"].to_numpy(float), "high": g["high"].to_numpy(float),
"low": g["low"].to_numpy(float), "dvol": dv, "asset": asset,
"dvol_chain": dvol_chain, "real": cal is not None,
"dt": pd.to_datetime(g["timestamp"], unit="ms", utc=True).to_numpy(),
"n": len(g)}
# --------------------------------------------------------------------------
# Pricing di una struttura: ritorna (premio_netto_incassato, funzione_payoff(ST))
# premio>0 = struttura a CREDITO (vendi); payoff e' il valore terminale (>=0 per long opt).
# Convenzione PnL trade: net = (premio_incassato - payoff_terminale)/S0 - costi (per credito)
# Tutto normalizzato sul SPOT (frazione), cosi' e' confrontabile fra asset/epoche.
# --------------------------------------------------------------------------
STRUCTURES = ["short_put", "short_call", "short_strangle", "put_spread",
"call_spread", "iron_condor", "long_put", "long_call", "long_straddle"]
def _legs_for(struct, S, otm, width):
kp = S * (1 - otm); kc = S * (1 + otm)
kp2 = S * (1 - otm - width); kc2 = S * (1 + otm + width)
return {
"short_put": [("P", kp, -1)], "short_call": [("C", kc, -1)],
"short_strangle": [("P", kp, -1), ("C", kc, -1)],
"put_spread": [("P", kp, -1), ("P", kp2, +1)],
"call_spread": [("C", kc, -1), ("C", kc2, +1)],
"iron_condor": [("P", kp, -1), ("P", kp2, +1), ("C", kc, -1), ("C", kc2, +1)],
"long_put": [("P", kp, +1)], "long_call": [("C", kc, +1)],
"long_straddle": [("P", S, +1), ("C", S, +1)],
}[struct]
def _price_real(struct, S, dte, scale, otm, width, cal):
"""Pricing REALE dalla superficie cerbero-bite. Ritorna (entry_cf_frac, legs, ok).
entry_cf_frac = cassa d'ingresso in frazione di spot (>0 = incassi); side-aware bid/ask;
ok=False se una gamba e' fuori dagli strike liquidi reali."""
legs = _legs_for(struct, S, otm, width)
entry = 0.0
for typ, K, sgn in legs:
q = _surf_lookup(cal, typ, K / S - 1.0, dte)
if q is None:
return 0.0, legs, False
prem, spread, _ = q
pf = prem / 100.0 * scale # premio frazione di spot, scalato a DVOL del giorno
if sgn < 0: # short: incassi il BID (~ ask*(1-spread))
entry += pf * (1 - spread)
else: # long: paghi l'ASK
entry -= pf
return entry, legs, True
def _price(struct, S, T, atm, otm, width):
"""Fallback SINTETICO (BS+skew). Usato solo se manca la calibrazione reale."""
legs = _legs_for(struct, S, otm, width)
prem = gross = 0.0
for typ, K, sgn in legs:
px = bs_put(S, K, T, iv_skew(np.log(K / S), atm)) if typ == "P" \
else bs_call(S, K, T, iv_skew(np.log(K / S), atm))
prem += -sgn * px / S
gross += abs(px) / S
return prem - 0.06 * gross, legs, True
def _payoff(legs, ST):
v = 0.0
for typ, K, sgn in legs:
intr = max(K - ST, 0.0) if typ == "P" else max(ST - K, 0.0)
v += sgn * intr # valore terminale delle opzioni che POSSIEDI/devi
return v # per le short questo e' cio' che PAGHI (sgn<0 -> negativo = debito)
def evaluate(data, spec, sl=None):
"""Backtest della struttura: roll giornaliero, hold dte giorni, PnL additivo.
spec = {structure, otm, width, dte}. Ritorna metriche con scoring PNL+%win, >=10 tr/mese.
"""
c, dv = data["close"], data["dvol"]
n = data["n"]
s, e = (sl if sl else (0, n))
struct = spec["structure"]
otm = float(spec["otm"]); width = float(spec.get("width", 0.05))
dte = int(spec["dte"])
T = dte / 365.0
cal = _load_calib(data["asset"]); dvol_chain = data["dvol_chain"]
rets = []
i = s
while i < e - dte:
S0 = c[i]; atm = dv[i]
if S0 <= 0 or atm <= 0:
i += 1; continue
if cal is not None: # PRICING REALE (cerbero-bite), scalato a DVOL del giorno
scale = min(max(atm / dvol_chain, 0.3), 4.0)
entry, legs, ok = _price_real(struct, S0, dte, scale, otm, width, cal)
if not ok: # strike fuori dalla superficie liquida reale -> non eseguibile
i += 1; continue
net = entry + _payoff(legs, c[i + dte]) / S0
else: # fallback sintetico
prem, legs, _ = _price(struct, S0, T, atm, otm, width)
net = prem + _payoff(legs, c[i + dte]) / S0
rets.append(net)
i += 1 # roll giornaliero (posizioni sovrapposte)
rets = np.array(rets)
nbars = e - s
months = nbars / TRADING_DAYS_MONTH
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, fitness=-1e6)
win = 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
if not qualified:
fitness = -1e6 + pnl
return dict(n_trades=n_tr, win_rate=win, pnl_pct=pnl, tpm=tpm, sharpe=sharpe,
avg_ret=avg, qualified=qualified, fitness=fitness)
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)
if __name__ == "__main__":
d = load_opt("ETH")
print("loaded", d["n"], "giorni", str(d["dt"][0])[:10], "->", str(d["dt"][-1])[:10],
"| dvol", round(float(np.nanmean(d["dvol"])), 2))
tr, va, te = splits3(d)
for st in ["short_put", "short_strangle", "iron_condor", "long_straddle", "put_spread"]:
sp = {"structure": st, "otm": 0.05, "width": 0.05, "dte": 14}
f = evaluate(d, sp, None); o = evaluate(d, sp, te)
print(f"{st:14} FULL pnl{f['pnl_pct']:8.0f} win{f['win_rate']*100:4.0f} tpm{f['tpm']:5.0f} "
f"Sh{f['sharpe']:6.1f} | OOS pnl{o['pnl_pct']:8.0f} win{o['win_rate']*100:4.0f} Sh{o['sharpe']:6.1f}")
+4
View File
@@ -14,6 +14,7 @@ import random
from pathlib import Path
from scripts.games import engine
from scripts.games import arena
from scripts.games.arena import random_spec, run_tournament, leaderboard, _normalize
SPECS_DIR = Path(os.environ.get("GAME_SPECS_DIR", "data/games/specs"))
@@ -59,6 +60,9 @@ def load_specs():
def main():
slip = float(os.environ.get("GAME_SLIP", "0.0"))
engine.set_slippage(slip)
if os.environ.get("GAME_NO_LIVE") == "1":
arena.set_no_live(True)
print("VINCOLO: solo strategie NON in live (no pairs, no zscore/breakout-reversion)")
if slip > 0:
print(f"SLIPPAGE attivo: {slip*100:.3f}%/lato "
f"(single-leg {2*slip*100:.2f}% RT extra, pairs {4*slip*100:.2f}% extra)")
+174
View File
@@ -0,0 +1,174 @@
"""
Arena del gioco-SESSION: 100 agenti ciechi cercano pattern ORARI intraday (fascia di
controllo -> finestra successiva) su due serie anonime (A=BTC, B=ETH). Torneo standard
(3 finestre, 90 epoche, cull 10%/10) col motore session_engine.
uv run python -m scripts.games.session_arena # 100 random (test)
GAME_SPECS_DIR=... GAME_OUT=... uv run python -m scripts.games.session_arena --from-specs
"""
from __future__ import annotations
import json
import os
import random
import sys
from pathlib import Path
from scripts.games.session_engine import load_session, splits3, evaluate
OUT = Path("data/games"); OUT.mkdir(parents=True, exist_ok=True)
PSPACE = dict(ctrl_hour=(0, 23, "i"), ctrl_len=(1, 6, "i"),
entry_thr=(0.0, 1.5, "f"), hold=(1, 12, "i"))
SERIES = ["A", "B"]
DIRECTIONS = ["trend", "reversion"]
def _rand(rng, lo, hi, typ):
return int(rng.randint(int(lo), int(hi))) if typ == "i" else round(rng.uniform(lo, hi), 3)
def random_spec(rng):
p = {k: _rand(rng, *v) for k, v in PSPACE.items()}
return {"series": rng.choice(SERIES), "direction": rng.choice(DIRECTIONS), "params": p}
def _normalize(spec):
out = {"series": spec.get("series") if spec.get("series") in SERIES else "A",
"direction": spec.get("direction") if spec.get("direction") in DIRECTIONS else "trend",
"params": {}}
src = spec.get("params", spec)
for k, (lo, hi, typ) in PSPACE.items():
v = src.get(k, (lo + hi) / 2)
try:
v = float(v)
except Exception:
v = (lo + hi) / 2
v = max(lo, min(hi, v))
out["params"][k] = int(round(v)) if typ == "i" else round(v, 3)
return out
def _flat(spec):
return {"direction": spec["direction"], **spec["params"]}
def mutate(spec, rng, strength=0.25):
s = json.loads(json.dumps(spec))
for k in rng.sample(list(PSPACE), k=rng.randint(1, 2)):
lo, hi, typ = PSPACE[k]
span = (hi - lo) * strength
nv = max(lo, min(hi, s["params"][k] + rng.uniform(-span, span)))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 3)
if rng.random() < 0.12:
s["direction"] = rng.choice(DIRECTIONS)
if rng.random() < 0.05:
s["series"] = rng.choice(SERIES)
return s
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid; self.spec = _normalize(spec); self.brief = brief
self.train_fit = self.valid_fit = -1e9; self.metrics = self.vmetrics = {}; self.alive = True
@property
def series(self):
return self.spec["series"]
def score(self, datasets, sm):
d = datasets[self.series]; tr, va, _ = sm[self.series]
self.metrics = evaluate(d, _flat(self.spec), tr); self.vmetrics = evaluate(d, _flat(self.spec), va)
self.train_fit = self.metrics["fitness"]; self.valid_fit = self.vmetrics["fitness"]
def run_tournament(specs, briefs=None, seed=2026, epochs=90, cull_every=10, cull_n=10,
out_name="session_result.json", log=print):
rng = random.Random(seed)
datasets = {"A": load_session("BTC"), "B": load_session("ETH")}
sm = {k: splits3(datasets[k]) for k in datasets}
briefs = briefs or [""] * len(specs)
agents = [Agent(i, s, briefs[i] if i < len(briefs) else "") for i, s in enumerate(specs)]
for a in agents:
a.score(datasets, sm)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} | best VALID {max(a.valid_fit for a in agents):.1f}")
for ep in range(1, epochs + 1):
for a in alive():
cand = _normalize(mutate(a.spec, rng))
d = datasets[cand["series"]]; tr, va, _ = sm[cand["series"]]
m = evaluate(d, _flat(cand), tr)
if m["fitness"] > a.train_fit:
a.spec, a.metrics, a.train_fit = cand, m, m["fitness"]
a.vmetrics = evaluate(d, _flat(cand), va); a.valid_fit = a.vmetrics["fitness"]
if ep % cull_every == 0:
av = sorted(alive(), key=lambda a: a.valid_fit)
k = cull_n if len(av) - cull_n >= 10 else max(0, len(av) - 10)
for a in av[:k]:
a.alive = False
log(f"[epoch {ep:2d}] cull {k:2d} -> {len(alive()):3d} | best VALID "
f"{max(a.valid_fit for a in alive()):.1f} | worst {min(a.valid_fit for a in alive()):.1f}")
survivors = sorted(alive(), key=lambda a: a.valid_fit, reverse=True)
results = []
for rank, a in enumerate(survivors, 1):
d = datasets[a.series]; _, _, te = sm[a.series]
results.append({"rank": rank, "agent": a.id, "spec": a.spec, "brief": a.brief,
"series": a.series, "train": a.metrics, "valid": a.vmetrics,
"test": evaluate(d, _flat(a.spec), te), "full": evaluate(d, _flat(a.spec), None)})
payload = {"n_agents": len(specs), "survivors": len(survivors), "results": results,
"reveal": {"A": "BTC", "B": "ETH"}, "game": "session"}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n===== CLASSIFICA SESSION (top %d) — fascia controllo -> finestra dopo =====" % top)
log(f"{'#':>2} {'ag':>4} {'ser':>3} {'h':>3} {'len':>3} {'thr%':>5} {'hold':>4} {'dir':>9} "
f"{'TEpnl%':>8} {'TEwin':>5} {'TEtpm':>6} {'TEsh':>6}")
for r in payload["results"][:top]:
sp = r["spec"]; te = r["test"]; p = sp["params"]
log(f"{r['rank']:>2} {r['agent']:>4} {sp['series']:>3} {p['ctrl_hour']:>3} {p['ctrl_len']:>3} "
f"{p['entry_thr']:>5.2f} {p['hold']:>4} {sp['direction']:>9} {te['pnl_pct']:>8.0f} "
f"{te['win_rate']*100:>4.0f}% {te['tpm']:>6.0f} {te['sharpe']:>6.1f}")
def load_specs(specs_dir, n=100):
rng = random.Random(7); specs, briefs = [], []
for i in range(n):
f = Path(specs_dir) / f"agent_{i}.json"; spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
params = {k: raw.get(k, raw.get("params", {}).get(k)) for k in PSPACE}
spec = _normalize({"series": {"X": "A", "Y": "B"}.get(raw.get("series"), raw.get("series")),
"direction": raw.get("direction"), "params": params})
briefs.append(str(raw.get("hypothesis", ""))[:300])
except Exception:
spec = None
if spec is None:
spec = random_spec(rng); briefs.append("(spec mancante -> random)")
specs.append(spec)
return specs, briefs
def main():
if "--from-specs" in sys.argv:
sd = os.environ.get("GAME_SPECS_DIR", "data/games/specs_sess")
on = os.environ.get("GAME_OUT", "session_result.json")
specs, briefs = load_specs(sd)
print(f"caricati {sum(1 for b in briefs if 'mancante' not in b)}/100 spec da agenti reali")
payload = run_tournament(specs, briefs=briefs, out_name=on)
else:
rng = random.Random(42)
payload = run_tournament([random_spec(rng) for _ in range(100)], seed=42)
leaderboard(payload)
rev = payload["reveal"]; w = payload["results"][0]; p = w["spec"]["params"]
print(f"\n>>> RIVELAZIONE: A={rev['A']}, B={rev['B']}. <<<")
print(f"VINCITORE: #{w['agent']} {w['series']} fascia h{p['ctrl_hour']} len{p['ctrl_len']} "
f"-> {w['spec']['direction']} hold{p['hold']}h thr{p['entry_thr']}%")
print(f" ipotesi: {w['brief']}")
print(f" TEST: PnL {w['test']['pnl_pct']:.0f}% | win {w['test']['win_rate']*100:.0f}% | "
f"{w['test']['tpm']:.0f} tr/mese | Sharpe {w['test']['sharpe']:.1f}")
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
"""
Motore del gioco-SESSION: pattern ORARI intraday. Ogni giorno si osserva il movimento
in una "fascia di controllo" [ctrl_hour, ctrl_hour+ctrl_len) e si scommette sul movimento
della finestra SUBITO DOPO (hold ore), seguendo (trend) o fadando (reversion) la fascia.
Cerca se esistono orari il cui comportamento ANTICIPA la finestra successiva, ripetibile nei
giorni. Dati orari reali (BTC=A, ETH=B), full history. PnL per-trade additivo, fee 0.10% RT.
"""
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
MIN_TRADES_PER_MONTH = 10.0
BARS_PER_MONTH = 24 * 30
def load_session(asset: str = "BTC"):
df = load_data(asset, "1h").copy()
dt = pd.to_datetime(df["datetime"])
return {"close": df["close"].to_numpy(float),
"open": df["open"].to_numpy(float),
"hour": dt.dt.hour.to_numpy(),
"day": (dt.dt.year * 366 + dt.dt.dayofyear).to_numpy(), # indice giorno
"dt": dt.to_numpy(), "n": len(df)}
def evaluate(data, spec, sl=None, fee=FEE_RT):
"""spec = {ctrl_hour, ctrl_len, entry_thr(%), direction, hold}. Una valutazione per giorno:
a fine fascia di controllo, se |ret_fascia| > entry_thr entra e tiene hold ore."""
c, hour = data["close"], data["hour"]
n = data["n"]
s, e = (sl if sl else (0, n))
ch = int(spec["ctrl_hour"]) % 24
cl = max(1, int(spec["ctrl_len"]))
thr = float(spec["entry_thr"]) / 100.0
hold = max(1, int(spec["hold"]))
sign = 1 if spec.get("direction", "trend") == "trend" else -1
# indici in cui inizia la fascia di controllo (bar all'ora ch)
starts = np.where(hour[s:e] == ch)[0] + s
rets = []
for st in starts:
be = st + cl - 1 # ultima barra della fascia
ex = be + hold # uscita
if ex >= e or st == 0:
continue
ctrl_ret = c[be] / c[st - 1] - 1.0 # ritorno della fascia (causale: chiude a be)
if abs(ctrl_ret) < thr:
continue
d = sign * (1 if ctrl_ret > 0 else -1) # trend segue, reversion fada
entry = c[be]; exit_px = c[ex]
net = d * (exit_px - entry) / entry - fee
rets.append(net)
rets = np.array(rets)
nbars = e - s
months = nbars / BARS_PER_MONTH
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, fitness=-1e6)
win = 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
if not qualified:
fitness = -1e6 + pnl
return dict(n_trades=n_tr, win_rate=win, pnl_pct=pnl, tpm=tpm, sharpe=sharpe,
avg_ret=avg, qualified=qualified, fitness=fitness)
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)
if __name__ == "__main__":
d = load_session("BTC"); tr, va, te = splits3(d)
for ch in [0, 8, 13, 20]:
for dr in ["trend", "reversion"]:
sp = {"ctrl_hour": ch, "ctrl_len": 2, "entry_thr": 0.3, "direction": dr, "hold": 4}
f = evaluate(d, sp, None); o = evaluate(d, sp, te)
print(f"h{ch:>2} {dr:>9} len2 hold4 thr0.3 | FULL pnl{f['pnl_pct']:7.0f} win{f['win_rate']*100:3.0f} "
f"tpm{f['tpm']:4.0f} Sh{f['sharpe']:5.1f} | OOS Sh{o['sharpe']:5.1f}")