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
+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}")