"""SH01 EXIT LAB — harness onesto e CONDIVISO per la ricerca di STOP-LOSS su SH01.

SH01 (shape-ML, logit walk-forward W24 H12 th0.58) NON ha TP/SL: esce SOLO a
orizzonte H=12 barre. Live (2026-06-05) si è preso il crash ETH intero: −15.6%
in un trade (long 1727.8 → 1594.35, leva 2x). Domanda di ricerca: esiste uno SL
che taglia le code SENZA distruggere l'edge (che vive nell'asimmetria dei
winner, win-rate ~50%)?

CONTRATTO ANTI-LOOK-AHEAD (vincolante, verificato da agenti avversari):
  - i livelli attivi nel bar j (`levels(..., j)`) possono usare SOLO dati <= j-1
    (il worker live fissa i livelli al close del bar precedente; il bar j li tocca);
  - `after_bar(..., j)` decide sul CLOSE del bar j (eseguibile al poll del tick);
  - indicatori causali: usare l'indice j-1 (es. ctx["atr14"][j-1]).

FILL GAP-AWARE (lezione exit-lab 2026-06-04 + crash live 2026-06-05): lo stop
intrabar NON filla "al livello" se il bar apre già oltre → fill = worse(level,
open[j]). Senza questo il backtest ha un bias PRO stop-stretti (54% dei fill
era ottimista). Il crash di oggi (feed flat 2h → gap 1655→1600) è il caso reale.

PROTOCOLLO ANTI-OVERFIT (vincolante, = exit_lab):
  - TRAIN = storico fino al 2023-11-01, OOS = dopo. SELEZIONE parametri SOLO
    sul train; OOS guardato una volta per il verdetto.
  - gate: miglioramento su ENTRAMBI gli asset (BTC e ETH), train E oos, con
    plateau sulla griglia (non una cella isolata). Metrica primaria: Sharpe e
    DD; il return non deve crollare (>= ~80% del baseline).
  - fee 0.10% RT × leva su tutto il notional.

Baseline = exit a orizzonte puro (max_bars=H, nessun TP/SL): parità ESATTA con
`explore_lab.simulate` verificata da `parity_check()`.

    uv run python scripts/analysis/sh01_exit_lab.py     # build cache + parity check
"""
from __future__ import annotations

import pickle
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))

LEV, POS, FEE_RT = 3.0, 0.15, 0.001
OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6)
ASSETS = ("BTC", "ETH")
CACHE = PROJECT_ROOT / "data" / "cache" / "sh01_exit_lab.pkl"


# ----------------------------------------------------------------------------- cache

def build_cache() -> dict:
    """Walk-forward SH01 (lento, ~minuti) → entries cache su disco."""
    from scripts.analysis.explore_lab import get_df              # noqa: E402
    from scripts.analysis.shape_ml_research import ml_wf_entries, atr  # noqa: E402
    from scripts.strategies.SH01_shape_ml import CONFIG          # noqa: E402

    out = {}
    for a in ASSETS:
        df = get_df(a, "1h")
        ents = ml_wf_entries(df, **CONFIG)
        out[a] = {
            "entries": [(int(e["i"]), int(e["d"]), int(e["max_bars"])) for e in ents],
            "open": df["open"].values.astype(float),
            "high": df["high"].values.astype(float),
            "low": df["low"].values.astype(float),
            "close": df["close"].values.astype(float),
            "ts_ms": df["timestamp"].values.astype("int64"),
            "atr14": atr(df, 14),
        }
        print(f"  {a}: {len(ents)} entries, {len(df)} bars", flush=True)
    CACHE.parent.mkdir(parents=True, exist_ok=True)
    with open(CACHE, "wb") as f:
        pickle.dump(out, f)
    return out


def load_sleeves(refresh: bool = False) -> dict:
    """{asset: ctx}. ctx = {entries, open, high, low, close, ts_ms, atr14}."""
    if CACHE.exists() and not refresh:
        with open(CACHE, "rb") as f:
            return pickle.load(f)
    return build_cache()


# ----------------------------------------------------------------------------- policy

class ExitPolicy:
    """Contratto per le policy di stop su SH01 (solo SL/uscite anticipate: il
    TP non esiste e l'exit a orizzonte max_bars resta SEMPRE il bound).

    open_trade(ctx, i, d) -> state    : livelli iniziali, SOLO dati <= i
    levels(ctx, i, d, j, st) -> (sl, mode) attivi nel bar j, SOLO dati <= j-1.
        sl=None → nessuno stop nel bar. mode: "intrabar" (tocco high/low, fill
        gap-aware worse(sl, open[j])) o "close" (stop solo se il CLOSE sfonda
        sl, uscita al close — stile EXIT-16).
    after_bar(ctx, i, d, j, st) -> bool : uscita discrezionale al CLOSE del bar
        j (dati <= j). Per giveback/time-stop/regime.
    Lo state è un dict mutabile per-trade (trailing ecc.)."""

    name = "base"

    def open_trade(self, ctx: dict, i: int, d: int) -> dict:
        return {}

    def levels(self, ctx: dict, i: int, d: int, j: int, st: dict):
        return None, "intrabar"

    def after_bar(self, ctx: dict, i: int, d: int, j: int, st: dict) -> bool:
        return False


# ----------------------------------------------------------------------------- engine

def simulate(ctx: dict, policy: ExitPolicy, fee_rt: float = FEE_RT,
             lev: float = LEV, pos: float = POS,
             t_lo: int | None = None, t_hi: int | None = None,
             gap_fill: bool = True, lag_close_exit: bool = False) -> dict:
    """Engine intrabar con policy di stop. Entries non sovrapposte (come
    explore_lab.simulate). t_lo/t_hi: filtro ms-epoch sull'ENTRY (train/oos).
    gap_fill: fill stop intrabar a worse(sl, open[j]) — tenere True.
    lag_close_exit: stress — le uscite "al close" fillano al close del bar
    successivo (poll in ritardo)."""
    o, h, l, c = ctx["open"], ctx["high"], ctx["low"], ctx["close"]
    ts = ctx["ts_ms"]
    n = len(c)
    cap = peak = 1000.0
    max_dd = 0.0
    fee = fee_rt * lev
    trades = wins = stops = 0
    bars_in = 0
    last_exit = -1
    yearly: dict[int, float] = {}
    rets: list[float] = []
    trade_rows: list[dict] = []

    for (i, d, mb) in ctx["entries"]:
        if i <= last_exit or i + 1 >= n:
            continue
        if t_lo is not None and ts[i] < t_lo:
            continue
        if t_hi is not None and ts[i] >= t_hi:
            continue
        entry = c[i]
        st = policy.open_trade(ctx, i, d)
        exit_p, j, reason = c[min(i + mb, n - 1)], min(i + mb, n - 1), "time"
        for k in range(1, mb + 1):
            j = i + k
            if j >= n:
                j, exit_p, reason = n - 1, c[n - 1], "eod"
                break
            sl, mode = policy.levels(ctx, i, d, j, st)
            if sl is not None and mode == "intrabar":
                hit = (l[j] <= sl) if d == 1 else (h[j] >= sl)
                if hit:
                    if gap_fill:
                        exit_p = min(sl, o[j]) if d == 1 else max(sl, o[j])
                    else:
                        exit_p = sl
                    reason = "stop"
                    break
            if sl is not None and mode == "close":
                brk = (c[j] < sl) if d == 1 else (c[j] > sl)
                if brk:
                    jj = min(j + 1, n - 1) if lag_close_exit else j
                    exit_p, j, reason = c[jj], jj, "stop"
                    break
            if policy.after_bar(ctx, i, d, j, st):
                jj = min(j + 1, n - 1) if lag_close_exit else j
                exit_p, j, reason = c[jj], jj, "policy"
                break
            if k == mb:
                exit_p, reason = c[j], "time"
        ret = (exit_p - entry) / entry * d * lev - fee
        cb = cap
        cap = max(cb + cb * pos * ret, 10.0)
        peak = max(peak, cap)
        max_dd = max(max_dd, (peak - cap) / peak)
        trades += 1
        wins += ret > 0
        stops += reason == "stop"
        bars_in += (j - i)
        last_exit = j
        rets.append(ret * pos)
        yr = pd.Timestamp(ts[i], unit="ms", tz="UTC").year
        yearly[yr] = yearly.get(yr, 0.0) + ret * 100
        trade_rows.append({"i": i, "j": j, "d": d, "ret": ret, "reason": reason})

    sharpe = (float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets)))
              if len(rets) > 1 and np.std(rets) > 0 else 0.0)
    return {
        "trades": trades,
        "win": wins / trades * 100 if trades else 0.0,
        "stop_rate": stops / trades * 100 if trades else 0.0,
        "ret": (cap / 1000 - 1) * 100,
        "dd": max_dd * 100,
        "sharpe": sharpe,
        "worst": min(rets) * 100 if rets else 0.0,   # peggior trade, % equity (ret*pos)
        "yearly": yearly,
        "_trades": trade_rows,
    }


def evaluate(policy: ExitPolicy, sleeves: dict | None = None, **kw) -> dict:
    """train (fino al 2023-11-01) e oos (dopo) per BTC e ETH. Stampa sintesi."""
    sleeves = sleeves or load_sleeves()
    out = {}
    for a in ASSETS:
        ctx = sleeves[a]
        tr = simulate(ctx, policy, t_hi=OOS_START_MS, **kw)
        oo = simulate(ctx, policy, t_lo=OOS_START_MS, **kw)
        out[a] = {"train": tr, "oos": oo}
        print(f"  {policy.name:<28s} {a}: "
              f"TRAIN ret={tr['ret']:>+7.0f}% dd={tr['dd']:>4.0f}% shrp={tr['sharpe']:>5.2f} "
              f"worst={tr['worst']:>+5.1f}% stop={tr['stop_rate']:>4.1f}% | "
              f"OOS ret={oo['ret']:>+6.0f}% dd={oo['dd']:>4.0f}% shrp={oo['sharpe']:>5.2f} "
              f"worst={oo['worst']:>+5.1f}%", flush=True)
    return out


# ----------------------------------------------------------------------------- parity

def parity_check() -> bool:
    """Baseline (nessuno stop) == explore_lab.simulate sugli stessi entries."""
    from scripts.analysis.explore_lab import get_df, simulate as ref_sim  # noqa: E402

    sleeves = load_sleeves()
    ok = True
    for a in ASSETS:
        ctx = sleeves[a]
        mine = simulate(ctx, ExitPolicy())
        df = get_df(a, "1h")
        ents = [{"i": i, "d": d, "max_bars": mb, "tp": None, "sl": None}
                for (i, d, mb) in ctx["entries"]]
        ref = ref_sim(ents, df)
        same = (abs(mine["ret"] - ref["ret"]) < 1e-6 and mine["trades"] == ref["trades"]
                and abs(mine["dd"] - ref["dd"]) < 1e-6)
        ok &= same
        print(f"  parity {a}: mine ret={mine['ret']:+.2f}% trades={mine['trades']} "
              f"| ref ret={ref['ret']:+.2f}% trades={ref['trades']} -> {'OK' if same else 'MISMATCH'}")
    return ok


if __name__ == "__main__":
    print("build cache (walk-forward SH01, puo' richiedere minuti)...")
    load_sleeves(refresh="--refresh" in sys.argv)
    print("parity check baseline vs explore_lab.simulate:")
    ok = parity_check()
    print("baseline train/oos:")
    evaluate(ExitPolicy())
    sys.exit(0 if ok else 1)
