chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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])
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user