feat(pairs): attiva ETH/BTC 15m flat-skip in PORT06 (BLEND, mezza size)
Origine: gioco "Blind Traders" (100 agenti ciechi su BTC/ETH anonimizzati) -> vincitore = spread ETH/BTC reversion a 15m. Testato sul serio col gate PORT06: non duplicato (corr 1h vs 15m = 0.37), robusto (16/16 celle Sharpe>1), edge NON artefatto delle candele flat ETH 15m (filtrandole resta l'83% dello Sharpe). Percorso live costruito e validato: - pairs_research.pairs_sim_flat: engine generalizzato con exit LIVE-REALIZABLE (arma exit_ready, esce alla 1a barra pulita); regression-lock a pairs_sim. - PairsWorker: flat_skip + exit_ready + rilevamento flat da OHLC (1h byte-exact). - runner: fetch diretto dei timeframe sub-orari + override position_size per-sleeve. - validate_worker_pairs: replay worker == backtest a 15m (8452 vs 8453 trade). - _defs/build_everything: sleeve PR_ETHBTC_15M (mezza size, pos 0.10) -> PORT06 FULL 6.43->7.20, OOS 8.58->9.66, DD giu'. Rischio bilanciato col 1h. - smoke live: Cerbero serve candele 15m fresche; worker ticca. Diari docs/diary/2026-06-09-*. Caveat slippage: mezza size = blend-tilt prudente. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
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": {"1h": "T1", "15m": "T2", "5m": "T3"}.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,231 @@
|
||||
"""
|
||||
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 = ["1h", "15m", "5m"] # timing diversi su cui competono gli agenti
|
||||
|
||||
|
||||
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 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"])
|
||||
spec["params"]["direction"] = rng.choice(DIRECTIONS)
|
||||
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"
|
||||
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")
|
||||
out["params"]["direction"] = d if d in DIRECTIONS else "reversion"
|
||||
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):
|
||||
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 = mutate(a.spec, rng)
|
||||
data = datasets[a.tf]
|
||||
tr, va, _ = splits_map[a.tf]
|
||||
m = evaluate(data, cand, tr)
|
||||
if m["fitness"] > a.train_fit:
|
||||
a.spec = _normalize(cand)
|
||||
a.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 / "tournament_result.json").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,323 @@
|
||||
"""
|
||||
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, "1h": 24 * 30} # barre/mese per tf
|
||||
MIN_TRADES_PER_MONTH = 10.0
|
||||
|
||||
# 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_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_data("BTC", tf).copy()
|
||||
eth = load_data("ETH", tf).copy()
|
||||
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,83 @@
|
||||
"""
|
||||
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.arena import random_spec, run_tournament, leaderboard, _normalize
|
||||
|
||||
SPECS_DIR = Path("data/games/specs")
|
||||
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 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)
|
||||
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()
|
||||
Reference in New Issue
Block a user