chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita

Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 15:16:03 +00:00
parent 8401a280b9
commit 14522262e6
383 changed files with 1971 additions and 779 deletions
+100
View File
@@ -0,0 +1,100 @@
"""
agent_brief — genera il "digest" ANONIMO che ogni agente cieco riceve.
L'agente non sa che sono BTC/ETH ne' che e' crypto: vede solo due serie X e Y
(rinominate dal motore A/B), una finestra normalizzata (base 100) e statistiche
aggregate. Da queste deve proporre una regola che "anticipi" i movimenti.
Genera anche il MENU dei blocchi (famiglie + range parametri) che l'agente puo'
comporre, in modo che l'output sia una spec backtestabile.
"""
from __future__ import annotations
import json
import numpy as np
from scripts.games.engine import load_anon
def _stats(close, high, low):
r = np.diff(np.log(close))
r = r[np.isfinite(r)]
out = {
"n_bars": int(len(close)),
"ret_vol_pct": round(float(np.std(r) * 100), 4),
"ret_autocorr_lag1": round(float(np.corrcoef(r[:-1], r[1:])[0, 1]), 4),
"ret_autocorr_lag5": round(float(np.corrcoef(r[:-5], r[5:])[0, 1]), 4),
"pct_up_bars": round(float(np.mean(r > 0) * 100), 2),
"skew": round(float(((r - r.mean()) ** 3).mean() / (r.std() ** 3 + 1e-12)), 3),
"kurtosis": round(float(((r - r.mean()) ** 4).mean() / (r.std() ** 4 + 1e-12)), 2),
}
# tendenza a rientrare dopo grandi mosse (|z|>2): segno del rendimento successivo
z = (r - r.mean()) / (r.std() + 1e-12)
big = np.where(np.abs(z[:-1]) > 2)[0]
if len(big) > 20:
nxt = r[big + 1]
same = np.sign(r[big]) == np.sign(nxt)
out["after_big_move_continues_pct"] = round(float(np.mean(same) * 100), 1)
return out
def make_digest(tf: str, window: int = 60, seed: int = 0):
data = load_anon(tf)
n = data["n"]
# finestra recente normalizzata (base 100) per "vedere" la forma
s = max(0, n - window)
dig = {"timeframe_id": {"5m": "T1", "15m": "T2", "30m": "T3", "1h": "T4",
"2h": "T5", "4h": "T6", "1d": "T7"}.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"]),
"recent_window_norm": [round(float(v), 2) for v in norm],
}
# relazione fra le due serie
ra = np.diff(np.log(data["A"]["close"]))
rb = np.diff(np.log(data["B"]["close"]))
m = min(len(ra), len(rb))
dig["XY_return_correlation"] = round(float(np.corrcoef(ra[:m], rb[:m])[0, 1]), 4)
lr = np.log(data["A"]["close"][:m + 1] / data["B"]["close"][:m + 1])
dig["XY_logratio_ret_autocorr"] = round(
float(np.corrcoef(np.diff(lr)[:-1], np.diff(lr)[1:])[0, 1]), 4)
return dig
MENU = {
"obiettivo": ("Proponi UNA regola che anticipi i movimenti futuri per un PnL "
"netto positivo dopo costi (0.10% andata+ritorno per trade). "
"Servono >=10 operazioni al mese. Non sai cosa siano X e Y."),
"famiglie": {
"zscore": "fade/segui lo z-score del prezzo su 'lookback' barre (entry_thr in sigma)",
"breakout": "rottura del canale max/min su 'lookback' barre (reversion=fade la rottura)",
"ma_cross": "incrocio EMA veloce(lookback)/lenta(lookback*slow_mult)",
"rsi": "RSI(lookback); entry_thr scala le bande attorno a 50",
"momentum": "rendimento su 'lookback' barre vs soglia entry_thr (%)",
"pairs": "market-neutral sullo z del log-rapporto X/Y (long una/short l'altra)",
},
"direzione": ["reversion (vai contro la mossa)", "trend (segui la mossa)"],
"serie": ["X", "Y (solo per single-family)", "pairs usa entrambe"],
"exit": "tp_atr / sl_atr (in unita' ATR), max_bars (durata massima)",
"range": {
"lookback": "5-120", "entry_thr": "1.0-3.5", "tp_atr": "0.5-4.0",
"sl_atr": "1.0-5.0", "max_bars": "6-120", "slow_mult": "2-6",
"exit_thr (pairs)": "0.2-1.0",
},
"output_schema": {
"family": "una di [zscore,breakout,ma_cross,rsi,momentum,pairs]",
"series": "X|Y|AB(pairs)", "direction": "reversion|trend",
"params": "dict coi parametri scelti", "hypothesis": "1-2 frasi: cosa hai notato",
},
}
if __name__ == "__main__":
import sys
tf = sys.argv[1] if len(sys.argv) > 1 else "1h"
print(json.dumps(make_digest(tf), indent=2)[:2000])
+253
View File
@@ -0,0 +1,253 @@
"""
Arena — tournament orchestrator per il gioco "Blind Traders".
100 agenti partono da una spec di strategia (creata alla cieca: vedi
agent_brief.py / workflow). L'orchestratore valuta ogni spec con il backtest
deterministico (engine.evaluate) su TRAIN, da' epoche di elaborazione (ogni
agente affina la propria strategia via hill-climb sui parametri) e OGNI 10
EPOCHE blocca il 10% meno profittevole. Restano i 10 piu' profittevoli.
Punteggio = fitness su PNL + %win, con vincolo >=10 trade/mese (engine).
"""
from __future__ import annotations
import json
import random
from pathlib import Path
import numpy as np
from scripts.games.engine import load_anon, splits3, evaluate
OUT = Path("data/games")
OUT.mkdir(parents=True, exist_ok=True)
# Spazio parametri per famiglia (min, max, tipo)
SPACE = {
"zscore": dict(lookback=(10, 100, "i"), entry_thr=(1.0, 3.5, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"breakout": dict(lookback=(12, 120, "i"), entry_thr=(0.0, 0.0, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"ma_cross": dict(lookback=(5, 50, "i"), slow_mult=(2.0, 6.0, "f"),
entry_thr=(0.0, 0.0, "f"), tp_atr=(0.5, 4.0, "f"),
sl_atr=(1.0, 5.0, "f"), max_bars=(6, 72, "i")),
"rsi": dict(lookback=(7, 30, "i"), entry_thr=(1.0, 4.0, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"momentum": dict(lookback=(6, 72, "i"), entry_thr=(1.0, 6.0, "f"),
tp_atr=(0.5, 4.0, "f"), sl_atr=(1.0, 5.0, "f"),
max_bars=(6, 72, "i")),
"pairs": dict(lookback=(20, 120, "i"), entry_thr=(1.5, 3.0, "f"),
exit_thr=(0.2, 1.0, "f"), max_bars=(24, 120, "i")),
}
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":
return int(rng.randint(int(lo), int(hi)))
return round(rng.uniform(lo, hi), 3)
def random_spec(rng):
if not NO_LIVE and rng.random() < 0.25:
fam = "pairs"
else:
fam = rng.choice(SINGLE_FAMILIES)
params = {}
for k, (lo, hi, typ) in SPACE[fam].items():
params[k] = _rand_param(rng, lo, hi, typ)
spec = {"family": fam, "params": params, "tf": rng.choice(TIMEFRAMES)}
if fam == "pairs":
spec["series"] = "AB"
else:
spec["series"] = rng.choice(["A", "B"])
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
def mutate(spec, rng, strength=0.25):
"""Perturba la spec (hill-climb). Per lo piu' numerica; raramente
cambia direzione/serie. La famiglia resta fissa (identita' dell'agente)."""
s = json.loads(json.dumps(spec))
fam = s["family"]
# perturba 1-2 parametri numerici
keys = [k for k in SPACE[fam] if SPACE[fam][k][0] != SPACE[fam][k][1]]
for k in rng.sample(keys, k=min(len(keys), rng.randint(1, 2))):
lo, hi, typ = SPACE[fam][k]
cur = s["params"][k]
span = (hi - lo) * strength
nv = cur + rng.uniform(-span, span)
nv = max(lo, min(hi, nv))
s["params"][k] = int(round(nv)) if typ == "i" else round(nv, 3)
if fam != "pairs":
if rng.random() < 0.10:
s["params"]["direction"] = rng.choice(DIRECTIONS)
if rng.random() < 0.05:
s["series"] = rng.choice(["A", "B"])
# il timeframe resta l'identita' dell'agente (timing fisso) -> non muta
return s
def _normalize(spec):
"""Completa/ripulisce una spec proposta da un agente (robustezza)."""
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)
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)
out["tf"] = spec.get("tf") if spec.get("tf") in TIMEFRAMES else "1h"
if fam == "pairs":
out["series"] = "AB"
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")
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
class Agent:
def __init__(self, aid, spec, brief=""):
self.id = aid
self.spec = _normalize(spec)
self.brief = brief # cosa "dice" l'agente (ipotesi NL)
self.train_fit = -1e9 # criterio di hill-climb (l'agente ottimizza qui)
self.valid_fit = -1e9 # criterio dell'orchestratore (cull + rank)
self.metrics = {} # metriche TRAIN
self.vmetrics = {} # metriche VALID
self.alive = True
self.culled_epoch = None
@property
def tf(self):
return self.spec.get("tf", "1h")
def score(self, datasets, splits_map):
data = datasets[self.tf]
tr, va, _ = splits_map[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=7,
epochs=90, cull_every=10, cull_n=10, log=print,
out_name="tournament_result.json"):
rng = random.Random(seed)
# carica solo i timeframe effettivamente usati dagli agenti
used_tfs = sorted({_normalize(s).get("tf", "1h") for s in specs})
datasets = {tf: load_anon(tf) for tf in used_tfs}
splits_map = {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, splits_map)
alive = lambda: [a for a in agents if a.alive]
log(f"[epoch 0] {len(alive())} agenti | best VALID fit "
f"{max(a.valid_fit for a in agents):.1f}")
history = []
for ep in range(1, epochs + 1):
# elaborazione: l'agente affina sul TRAIN (cio' che vede); ricalcola VALID
for a in alive():
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 = cand
a.metrics, a.train_fit = m, m["fitness"]
a.vmetrics = evaluate(data, a.spec, va)
a.valid_fit = a.vmetrics["fitness"]
# cull ogni N epoche: l'ORCHESTRATORE blocca il 10% meno profittevole
# in VALIDATION (generalizzazione, non overfit sul train)
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)
# report finale: TEST = OOS puro mai toccato dall'ottimizzazione
results = []
for rank, a in enumerate(survivors, 1):
data = datasets[a.tf]
_, _, te = splits_map[a.tf]
test = evaluate(data, a.spec, te)
full = evaluate(data, a.spec, None)
results.append({
"rank": rank, "agent": a.id, "spec": a.spec, "brief": a.brief,
"tf": a.tf, "train": a.metrics, "valid": a.vmetrics,
"test": test, "full": full,
})
payload = {"n_agents": len(specs), "epochs": epochs,
"survivors": len(survivors), "results": results,
"history": history,
"reveal": {"A": "BTC", "B": "ETH", "tf": "1h"}}
(OUT / out_name).write_text(json.dumps(payload, indent=2))
return payload
def leaderboard(payload, top=10, log=print):
log("\n================ CLASSIFICA FINALE (top %d) ================" % top)
log("VALID = finestra su cui l'orchestratore giudica | TEST = OOS puro (mai ottimizzato)")
log(f"{'#':>2} {'ag':>4} {'tf':>3} {'famiglia':>9} {'ser':>3} {'dir':>9} "
f"{'TEpnl%':>8} {'TEwin':>5} {'TEtpm':>6} {'TEsh':>5} {'VApnl%':>8} {'VAwin':>5}")
for r in payload["results"][:top]:
sp = r["spec"]; te = r["test"]; va = r["valid"]
d = sp["params"].get("direction", "-")
log(f"{r['rank']:>2} {r['agent']:>4} {sp.get('tf','1h'):>3} {sp['family']:>9} "
f"{sp['series']:>3} {d:>9} {te['pnl_pct']:>8.0f} {te['win_rate']*100:>4.0f}% "
f"{te['tpm']:>6.1f} {te['sharpe']:>5.1f} {va['pnl_pct']:>8.0f} "
f"{va['win_rate']*100:>4.0f}%")
if __name__ == "__main__":
import sys
# modalita' test: 100 agenti random
rng = random.Random(42)
specs = [random_spec(rng) for _ in range(100)]
payload = run_tournament(specs, seed=42)
leaderboard(payload)
+344
View File
@@ -0,0 +1,344 @@
"""
Game engine — "Blind Traders" tournament.
100 agenti ricevono due serie anonime (A, B) — in realta' BTC e ETH 1h — e
propongono strategie senza sapere cosa sono. L'orchestratore (questo motore)
valuta ogni strategia con un backtest deterministico, causale e fee-aware, e
assegna un punteggio su %win + PNL con vincolo >=10 trade/mese.
Tutto causale (nessun look-ahead): i segnali alla barra i usano solo dati
fino a close[i]; l'ingresso e' a close[i], le uscite TP/SL/max_bars intrabar
dalle barre successive.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from src.data.downloader import load_data
FEE_RT = 0.001 # 0.10% round-trip (taker Deribit, baseline progetto)
TF_BPM = {"5m": 12 * 24 * 30, "15m": 4 * 24 * 30, "30m": 2 * 24 * 30,
"1h": 24 * 30, "2h": 12 * 30, "4h": 6 * 30, "1d": 30} # barre/mese per tf
MIN_TRADES_PER_MONTH = 10.0
# timeframe non presenti come parquet -> resamplati da una base (open=first,
# high=max, low=min, close=last, volume=sum). Permette "timing diversi" nel gioco.
_RESAMPLE = {"30m": ("15m", "30min"), "2h": ("1h", "2h"),
"4h": ("1h", "4h"), "1d": ("1h", "1D")}
# Slippage per LATO (oltre alle fee). 0 = come prima. Single-leg paga 2 lati
# (ingresso+uscita), i pairs ne pagano 4 (2 gambe x 2 lati).
_SLIP = 0.0
def set_slippage(slip_per_side: float):
global _SLIP
_SLIP = float(slip_per_side)
# --------------------------------------------------------------------------
# Dati anonimizzati
# --------------------------------------------------------------------------
def _load_tf(asset: str, tf: str):
"""Carica un asset al timeframe tf (parquet diretto, o resample da una base)."""
if tf in _RESAMPLE:
base_tf, rule = _RESAMPLE[tf]
d = load_data(asset, base_tf).copy()
d["dt"] = pd.to_datetime(d["datetime"])
g = d.set_index("dt").resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last",
"volume": "sum"}).dropna(subset=["open", "close"])
g = g.reset_index()
g["datetime"] = g["dt"]
g["timestamp"] = (g["dt"].astype("int64") // 1_000_000)
return g.drop(columns=["dt"])
return load_data(asset, tf).copy()
def load_anon(tf: str = "1h"):
"""Carica BTC->A, ETH->B allineati sull'intersezione temporale.
Ritorna un dict con array OHLC per A e B + datetime. I nomi reali NON
compaiono: gli agenti vedono solo 'A' e 'B'.
"""
btc = _load_tf("BTC", tf)
eth = _load_tf("ETH", tf)
for d in (btc, eth):
d["dt"] = pd.to_datetime(d["datetime"])
btc = btc.set_index("dt")
eth = eth.set_index("dt")
idx = btc.index.intersection(eth.index)
btc = btc.loc[idx].sort_index()
eth = eth.loc[idx].sort_index()
out = {"dt": idx.to_numpy()}
for name, d in (("A", btc), ("B", eth)):
out[name] = {
"open": d["open"].to_numpy(float),
"high": d["high"].to_numpy(float),
"low": d["low"].to_numpy(float),
"close": d["close"].to_numpy(float),
"volume": d["volume"].to_numpy(float),
}
out["n"] = len(idx)
out["tf"] = tf
out["bpm"] = TF_BPM[tf]
return out
# --------------------------------------------------------------------------
# Indicatori causali (vettorizzati)
# --------------------------------------------------------------------------
def _roll_mean(x, w):
return pd.Series(x).rolling(w).mean().to_numpy()
def _roll_std(x, w):
return pd.Series(x).rolling(w).std(ddof=0).to_numpy()
def _ema(x, w):
return pd.Series(x).ewm(span=w, adjust=False).mean().to_numpy()
def _atr(high, low, close, w=14):
pc = np.roll(close, 1)
pc[0] = close[0]
tr = np.maximum(high - low, np.maximum(np.abs(high - pc), np.abs(low - pc)))
return pd.Series(tr).rolling(w).mean().to_numpy()
def _rsi(close, w=14):
d = np.diff(close, prepend=close[0])
up = np.where(d > 0, d, 0.0)
dn = np.where(d < 0, -d, 0.0)
ru = pd.Series(up).ewm(alpha=1 / w, adjust=False).mean().to_numpy()
rd = pd.Series(dn).ewm(alpha=1 / w, adjust=False).mean().to_numpy()
rs = ru / (rd + 1e-12)
return 100 - 100 / (1 + rs)
# --------------------------------------------------------------------------
# Famiglie di segnale -> array di posizione desiderata {-1,0,+1} alla barra i
# (causale: usa solo dati fino a close[i]). +1 = long, -1 = short.
# --------------------------------------------------------------------------
def _signal_single(o, family, p):
"""Segnale per una singola serie. Ritorna (pos_target, atr)."""
close = o["close"]
high, low = o["high"], o["low"]
n = len(close)
atr = _atr(high, low, close, 14)
pos = np.zeros(n)
lb = max(2, int(p["lookback"]))
thr = float(p["entry_thr"])
sign = 1 if p.get("direction", "reversion") == "trend" else -1
if family == "zscore":
ma = _roll_mean(close, lb)
sd = _roll_std(close, lb)
z = (close - ma) / (sd + 1e-12)
pos = np.where(z > thr, sign * -1.0, np.where(z < -thr, sign * 1.0, 0.0))
elif family == "breakout":
hh = pd.Series(high).rolling(lb).max().shift(1).to_numpy()
ll = pd.Series(low).rolling(lb).min().shift(1).to_numpy()
up = close > hh
dn = close < ll
# trend: break-up=long ; reversion: break-up=short
pos = np.where(up, sign * 1.0, np.where(dn, sign * -1.0, 0.0))
elif family == "ma_cross":
fast = _ema(close, lb)
slow = _ema(close, max(lb + 2, int(lb * p.get("slow_mult", 3))))
pos = np.where(fast > slow, sign * 1.0, sign * -1.0)
elif family == "rsi":
r = _rsi(close, lb)
hi = 50 + thr * 10
lo = 50 - thr * 10
pos = np.where(r > hi, sign * -1.0, np.where(r < lo, sign * 1.0, 0.0))
elif family == "momentum":
ret = close / np.roll(close, lb) - 1
ret[:lb] = 0
pos = np.where(ret > thr / 100, sign * 1.0,
np.where(ret < -thr / 100, sign * -1.0, 0.0))
else:
raise ValueError(f"unknown family {family}")
pos = np.nan_to_num(pos)
return pos, atr
# --------------------------------------------------------------------------
# Backtest single-series (long/short con TP/SL/max_bars intrabar)
# --------------------------------------------------------------------------
def _backtest_single(o, pos, atr, p, fee=FEE_RT):
close, high, low = o["close"], o["high"], o["low"]
n = len(close)
tp_atr = float(p.get("tp_atr", 2.0))
sl_atr = float(p.get("sl_atr", 2.0))
max_bars = int(p.get("max_bars", 24))
rets = [] # net return per trade
# warmup
start = max(int(p["lookback"]) + 15, 20)
# indici candidati: solo barre con segnale != 0 (salta le barre flat)
cand = np.flatnonzero(pos[start:n - 1]) + start
ci = 0
nc = len(cand)
while ci < nc:
i = int(cand[ci])
d = pos[i]
if d == 0 or np.isnan(atr[i]) or atr[i] <= 0:
ci += 1
continue
entry = close[i]
a = atr[i]
if d > 0:
tp = entry + tp_atr * a
sl = entry - sl_atr * a
else:
tp = entry - tp_atr * a
sl = entry + sl_atr * a
exit_px = None
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
hi, lo = high[j], low[j]
if d > 0:
if lo <= sl: # SL prioritario
exit_px = sl
break
if hi >= tp:
exit_px = tp
break
else:
if hi >= sl:
exit_px = sl
break
if lo <= tp:
exit_px = tp
break
j += 1
if exit_px is None:
exit_px = close[end]
j = end
gross = d * (exit_px - entry) / entry
net = gross - fee - 2 * _SLIP # 2 lati di slippage
rets.append(net)
# salta al primo ingresso candidato OLTRE l'uscita (no overlap)
ci = int(np.searchsorted(cand, j + 1, side="left"))
return np.array(rets)
# --------------------------------------------------------------------------
# Backtest cross-series (pairs market-neutral sullo z del log-ratio)
# --------------------------------------------------------------------------
def _backtest_pairs(A, B, p, fee=FEE_RT):
a, b = A["close"], B["close"]
n = len(a)
lb = max(5, int(p["lookback"]))
z_in = float(p["entry_thr"])
z_exit = float(p.get("exit_thr", 0.5))
max_bars = int(p.get("max_bars", 72))
lr = np.log(a / b)
ma = _roll_mean(lr, lb)
sd = _roll_std(lr, lb)
z = (lr - ma) / (sd + 1e-12)
rets = []
start = max(lb + 5, 20)
zabs = np.abs(z)
zabs[:start] = 0.0
zabs[np.isnan(zabs)] = 0.0
cand = np.flatnonzero(zabs[:n - 1] > z_in)
ci = 0
nc = len(cand)
while ci < nc:
i = int(cand[ci])
d = -1 if z[i] > z_in else 1 # spread alto -> short A/long B ; basso -> long A/short B
ea, eb = a[i], b[i]
j = i + 1
end = min(n - 1, i + max_bars)
while j <= end:
if abs(z[j]) <= z_exit:
break
j += 1
j = min(j, end)
# PnL = gamba A (dir d) + gamba B (dir -d), fee su 2 gambe
ra = d * (a[j] - ea) / ea
rb = -d * (b[j] - eb) / eb
net = ra + rb - 2 * fee - 4 * _SLIP # 2 gambe x 2 lati di slippage
rets.append(net)
ci = int(np.searchsorted(cand, j + 1, side="left"))
return np.array(rets)
# --------------------------------------------------------------------------
# Valutazione + scoring
# --------------------------------------------------------------------------
def evaluate(data, spec, sl=None, fee=FEE_RT):
"""Valuta una spec di strategia su uno slice [start,end) (sl=slice di indici).
spec = {family, series, params{...}}. Ritorna dict metriche.
"""
family = spec["family"]
series = spec.get("series", "A")
p = spec["params"]
def _slice(o):
if sl is None:
return o
s, e = sl
return {k: v[s:e] for k, v in o.items()}
if family == "pairs":
A = _slice(data["A"])
B = _slice(data["B"])
rets = _backtest_pairs(A, B, p, fee)
nbars = len(A["close"])
else:
o = _slice(data[series])
pos, atr = _signal_single(o, family, p)
rets = _backtest_single(o, pos, atr, p, fee)
nbars = len(o["close"])
n_tr = len(rets)
months = nbars / data.get("bpm", TF_BPM["1h"])
tpm = n_tr / months if months > 0 else 0.0
if n_tr == 0:
return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0,
sharpe=0.0, avg_ret=0.0, qualified=False, fitness=-1e6)
win_rate = float(np.mean(rets > 0))
pnl = float(np.sum(rets)) * 100 # PnL additivo (notional fisso), %
equity = float(np.prod(1 + rets) - 1) * 100 # equity compounding, %
avg = float(np.mean(rets)) * 100
sharpe = float(np.mean(rets) / (np.std(rets) + 1e-12) * np.sqrt(tpm * 12)) \
if np.std(rets) > 0 else 0.0
qualified = tpm >= MIN_TRADES_PER_MONTH
# fitness: PNL domina, win% come spinta secondaria; squalifica se pochi trade
fitness = pnl + 50.0 * win_rate
if not qualified:
fitness = -1e6 + pnl # ordinati ma fuori gioco
return dict(n_trades=n_tr, win_rate=win_rate, pnl_pct=pnl, equity_pct=equity,
tpm=tpm, sharpe=sharpe, avg_ret=avg, qualified=qualified,
fitness=fitness)
# Split a 3: TRAIN (hill-climb) / VALID (cull+rank dell'orchestratore) / TEST (OOS puro)
def splits3(data, train_frac=0.60, valid_frac=0.20):
n = data["n"]
c1 = int(n * train_frac)
c2 = int(n * (train_frac + valid_frac))
return (0, c1), (c1, c2), (c2, n)
# compat: split a 2 (train/oos)
def splits(data, train_frac=0.70):
n = data["n"]
cut = int(n * train_frac)
return (0, cut), (cut, n)
if __name__ == "__main__":
data = load_anon("1h")
print("loaded", data["n"], "bars,", data["dt"][0], "->", data["dt"][-1])
tr, oos = splits(data)
demo = {"family": "zscore", "series": "B",
"params": {"lookback": 20, "entry_thr": 2.0, "direction": "reversion",
"tp_atr": 1.5, "sl_atr": 2.0, "max_bars": 24}}
print("TRAIN", evaluate(data, demo, tr))
print("OOS ", evaluate(data, demo, oos))
+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}")
+88
View File
@@ -0,0 +1,88 @@
"""
run_game — carica le 100 strategie proposte dagli agenti ciechi (file in
data/games/specs/agent_*.json), lancia il torneo (epoche + cull) e stampa la
classifica finale, poi RIVELA cosa erano X e Y.
Se mancano agenti (file assenti o malformati) riempie con spec casuali, cosi'
il gioco gira sempre a 100 concorrenti.
"""
from __future__ import annotations
import json
import os
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"))
OUT_NAME = os.environ.get("GAME_OUT", "tournament_result.json")
N = 100
def load_specs():
rng = random.Random(123)
specs, briefs, sources = [], [], []
for i in range(N):
f = SPECS_DIR / f"agent_{i}.json"
spec = None
if f.exists():
try:
raw = json.loads(f.read_text())
fam = raw.get("family")
params = dict(raw.get("params", {}))
if "direction" in raw and "direction" not in params:
params["direction"] = raw["direction"]
spec = {"family": fam, "series": raw.get("series", "A"),
"tf": raw.get("tf", "1h"), "params": params}
# X->A, Y->B mapping (gli agenti vedono X/Y)
s = spec["series"]
spec["series"] = {"X": "A", "Y": "B", "AB": "AB",
"A": "A", "B": "B"}.get(s, "A")
spec = _normalize(spec)
briefs.append(str(raw.get("hypothesis", ""))[:300])
sources.append("agent")
except Exception as e:
spec = None
if spec is None:
spec = random_spec(rng)
briefs.append("(spec mancante -> sostituto casuale)")
sources.append("random")
specs.append(spec)
n_agent = sources.count("agent")
print(f"caricati {n_agent}/{N} spec da agenti reali, "
f"{N - n_agent} sostituiti casuali")
return specs, briefs
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)")
specs, briefs = load_specs()
payload = run_tournament(specs, briefs=briefs, seed=2026,
epochs=90, cull_every=10, cull_n=10, out_name=OUT_NAME)
leaderboard(payload, top=10)
rev = payload["reveal"]
print(f"\n>>> RIVELAZIONE: Serie X = {rev['A']}, Serie Y = {rev['B']} "
f"(timeframe base {rev['tf']}). Gli agenti non lo sapevano. <<<")
# vincitore
w = payload["results"][0]
sp = w["spec"]
print(f"\nVINCITORE: agente #{w['agent']} su {w['tf']} | {sp['family']} "
f"{sp['series']} {sp['params'].get('direction','')}")
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()
+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}")