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