"""TRACK C — Mean-reversion / range re-examination on CLEAN BTC/ETH (Deribit mainnet).

HONEST harness only. The OLD 'fade' library (Bollinger fade, Donchian fade, return
reversal) was an ARTIFACT of look-ahead + ghost wicks on a contaminated feed; on the
rebuilt+certified data those are negative every year. This script asks, skeptically:

    Does ANY short-horizon mean-reversion / range edge survive on clean BTC/ETH with a
    genuinely EXECUTABLE entry (direction + price decided with data <= close[i],
    fill at close[i]), net of realistic Deribit fees, out-of-sample and grid-robust?

Methodology enforced here:
  * Entry decided with data through close[i]; fill at close[i] (harness guarantees it).
    No entering "at the band edge" / candle extreme only known intrabar.
  * NET fees fee_rt=0.001 baseline + sweep {0.0005, 0.0015, 0.002}.
  * OOS 65/35 split + parameter grid across BOTH BTC & ETH.
  * Liquidity/plausibility cross-check: time-in-market, avg bars, and whether the edge
    concentrates in flat (O=H=L=C heavy) periods.

Run:
    uv run python scripts/research/trackC_meanrev.py            # full (slow, all TFs)
    uv run python scripts/research/trackC_meanrev.py --quick    # 1h + 15m only
"""
from __future__ import annotations

import argparse
import sys
import time
from pathlib import Path

import numpy as np
import pandas as pd

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from src.backtest.harness import load, backtest_signals, oos_split, Metrics


# ===========================================================================
# Indicator helpers — ALL causal: value at index i uses ONLY data through i.
# ===========================================================================
def zscore(close: np.ndarray, lookback: int) -> np.ndarray:
    s = pd.Series(close)
    ma = s.rolling(lookback).mean()
    sd = s.rolling(lookback).std(ddof=0)
    z = (s - ma) / sd
    return z.values, ma.values, sd.values


def rsi(close: np.ndarray, period: int) -> np.ndarray:
    s = pd.Series(close)
    d = s.diff()
    up = d.clip(lower=0.0)
    dn = (-d).clip(lower=0.0)
    # Wilder smoothing via ewm alpha=1/period (causal)
    ru = up.ewm(alpha=1.0 / period, adjust=False).mean()
    rd = dn.ewm(alpha=1.0 / period, adjust=False).mean()
    rs = ru / rd.replace(0, np.nan)
    out = 100 - 100 / (1 + rs)
    return out.values


def atr(df: pd.DataFrame, period: int) -> np.ndarray:
    h, l, c = df["high"].values, df["low"].values, df["close"].values
    pc = np.roll(c, 1)
    pc[0] = c[0]
    tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
    return pd.Series(tr).ewm(alpha=1.0 / period, adjust=False).mean().values


# ===========================================================================
# Signal generators — each returns a list[dict|None] length len(df).
# Direction/levels decided strictly with data through close[i].
# ===========================================================================
def sig_zfade(df, lookback=20, z=2.0, tp_mode="mean", tp_atr=1.0, sl_atr=2.0,
              max_bars=24, atr_p=14):
    """Bollinger / z-score fade. z<-thr -> long (reversion up); z>+thr -> short.
    TP at the moving mean (tp_mode='mean') or at tp_atr*ATR toward the mean.
    SL at sl_atr*ATR beyond entry. Entry at close[i]."""
    c = df["close"].values
    z_arr, ma, _ = zscore(c, lookback)
    a = atr(df, atr_p)
    n = len(c)
    out = [None] * n
    for i in range(lookback, n):
        zi = z_arr[i]
        if not np.isfinite(zi) or not np.isfinite(a[i]):
            continue
        px = c[i]
        if zi <= -z:
            direction = 1
            tp = ma[i] if tp_mode == "mean" else px + tp_atr * a[i]
            sl = px - sl_atr * a[i] if sl_atr else None
        elif zi >= z:
            direction = -1
            tp = ma[i] if tp_mode == "mean" else px - tp_atr * a[i]
            sl = px + sl_atr * a[i] if sl_atr else None
        else:
            continue
        # guardrail: never set TP on wrong side of entry
        if direction == 1 and tp <= px:
            tp = px + tp_atr * a[i]
        if direction == -1 and tp >= px:
            tp = px - tp_atr * a[i]
        out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": max_bars}
    return out


def sig_rsi2(df, period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12,
             atr_p=14, sma_filter=0):
    """RSI(2)-style oversold/overbought reversion. RSI<lo -> long, RSI>hi -> short.
    Optional trend filter: only long above SMA(sma_filter), only short below."""
    c = df["close"].values
    r = rsi(c, period)
    a = atr(df, atr_p)
    sma = pd.Series(c).rolling(sma_filter).mean().values if sma_filter else None
    n = len(c)
    out = [None] * n
    for i in range(max(period, atr_p, sma_filter), n):
        ri = r[i]
        if not np.isfinite(ri) or not np.isfinite(a[i]):
            continue
        px = c[i]
        if ri <= lo:
            if sma is not None and not (px > sma[i]):
                continue
            out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
                      "sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
        elif ri >= hi:
            if sma is not None and not (px < sma[i]):
                continue
            out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
                      "sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
    return out


def sig_retrev(df, ret_lb=1, thr_sigma=2.0, vol_lb=50, tp_atr=1.0, sl_atr=2.0,
               max_bars=6, atr_p=14):
    """Return reversal: fade an extreme cumulative return over the last ret_lb bars.
    Extreme = |ret| > thr_sigma * rolling std of that return. Entry at close[i]."""
    c = df["close"].values
    s = pd.Series(c)
    ret = np.log(s / s.shift(ret_lb))
    sd = ret.rolling(vol_lb).std(ddof=0)
    a = atr(df, atr_p)
    n = len(c)
    out = [None] * n
    rv = ret.values
    sv = sd.values
    for i in range(vol_lb + ret_lb, n):
        if not np.isfinite(rv[i]) or not np.isfinite(sv[i]) or sv[i] == 0 or not np.isfinite(a[i]):
            continue
        z = rv[i] / sv[i]
        px = c[i]
        if z <= -thr_sigma:
            out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
                      "sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
        elif z >= thr_sigma:
            out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
                      "sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
    return out


def sig_vwap(df, sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12, atr_p=14):
    """Rolling-VWAP distance reversion. Distance in std-of-distance units over a
    rolling session window. Far above VWAP -> short, far below -> long. Entry close[i]."""
    c = df["close"].values
    v = df["volume"].values.astype(float)
    tp = (df["high"].values + df["low"].values + c) / 3.0
    pv = pd.Series(tp * v)
    vol = pd.Series(v)
    vwap = (pv.rolling(sess_bars).sum() / vol.rolling(sess_bars).sum()).values
    dist = pd.Series(c - vwap)
    dsd = dist.rolling(sess_bars).std(ddof=0).values
    a = atr(df, atr_p)
    n = len(c)
    out = [None] * n
    for i in range(sess_bars * 2, n):
        if not np.isfinite(vwap[i]) or not np.isfinite(dsd[i]) or dsd[i] == 0 or not np.isfinite(a[i]):
            continue
        z = (c[i] - vwap[i]) / dsd[i]
        px = c[i]
        if z <= -thr:
            out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
                      "sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
        elif z >= thr:
            out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
                      "sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
    return out


# ===========================================================================
# Evaluation utilities
# ===========================================================================
def flat_fraction(df: pd.DataFrame) -> float:
    o, h, l, c = df["open"], df["high"], df["low"], df["close"]
    return float(((h == l) & (o == c)).mean())


def run_split(df, sigfn, params, fee_rt=0.001, leverage=1.0):
    """Run full / IS / OOS for a single config. Returns (full, is_, oos)."""
    entries = sigfn(df, **params)
    cut = oos_split(df, 0.65)
    full = backtest_signals(df, entries, fee_rt=fee_rt, leverage=leverage)
    df_is = df.iloc[:cut].reset_index(drop=True)
    df_oos = df.iloc[cut:].reset_index(drop=True)
    is_ = backtest_signals(df_is, sigfn(df_is, **params), fee_rt=fee_rt, leverage=leverage)
    oos = backtest_signals(df_oos, sigfn(df_oos, **params), fee_rt=fee_rt, leverage=leverage)
    return full, is_, oos


def hdr(title):
    print("\n" + "=" * 92)
    print(title)
    print("=" * 92)


# ===========================================================================
# Main
# ===========================================================================
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--quick", action="store_true", help="1h+15m only (skip slow 5m)")
    args = ap.parse_args()

    t0 = time.time()
    tfs = ["1h", "15m"] if args.quick else ["1h", "15m", "5m"]
    assets = ["BTC", "ETH"]

    # preload + liquidity sanity
    data = {}
    hdr("DATA / LIQUIDITY SANITY (flat-bar fraction O=H=L=C; should be ~0 on clean BTC/ETH)")
    for a in assets:
        for tf in tfs:
            df = load(a, tf)
            data[(a, tf)] = df
            print(f"  {a} {tf:>3s}: {len(df):>7d} bars  {df['datetime'].iloc[0].date()}→"
                  f"{df['datetime'].iloc[-1].date()}  flat={flat_fraction(df)*100:5.2f}%")

    # -------------------------------------------------------------------
    # PASS 1 — broad screen per family on 1h, both assets (IS/OOS).
    # -------------------------------------------------------------------
    hdr("PASS 1 — FAMILY SCREEN on 1h (honest entry, fee_rt=0.001, lev=1). "
        "Look for OOS>0 on BOTH assets.")
    families = {
        "ZFADE   z2/mean ": (sig_zfade, dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)),
        "ZFADE   z2.5/atr": (sig_zfade, dict(lookback=20, z=2.5, tp_mode="atr", tp_atr=1.5, sl_atr=2.0, max_bars=24)),
        "ZFADE   z3/mean ": (sig_zfade, dict(lookback=40, z=3.0, tp_mode="mean", sl_atr=3.0, max_bars=48)),
        "RSI2    10/90   ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
        "RSI2    5/95     ": (sig_rsi2, dict(period=2, lo=5, hi=95, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
        "RSI2    +trend   ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12, sma_filter=200)),
        "RETREV  2sig/6b ": (sig_retrev, dict(ret_lb=1, thr_sigma=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=6)),
        "RETREV  3sig/12b": (sig_retrev, dict(ret_lb=3, thr_sigma=3.0, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
        "VWAP    2/sess24": (sig_vwap, dict(sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
    }
    for name, (fn, params) in families.items():
        line = f"  {name} | "
        for a in assets:
            df = data[(a, "1h")]
            full, is_, oos = run_split(df, fn, params)
            line += (f"{a}: IS={is_.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
                     f"(tr={oos.n_trades:>4d} wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f} "
                     f"mkt={oos.time_in_market*100:>3.0f}% ab={oos.avg_bars:>4.1f})  ")
        print(line)

    # -------------------------------------------------------------------
    # PASS 2 — parameter GRID on the two most-promising families (z-fade, rsi2),
    # require OOS>0 on BOTH assets to count a cell as "surviving".
    # -------------------------------------------------------------------
    hdr("PASS 2 — GRID ROBUSTNESS (1h). A cell 'survives' only if OOS net>0 on BOTH BTC AND ETH.")

    def grid(fn, base, sweep, tf="1h"):
        keys = list(sweep.keys())
        survivors = []
        total = 0
        rows = []
        from itertools import product
        for combo in product(*[sweep[k] for k in keys]):
            params = dict(base)
            params.update(dict(zip(keys, combo)))
            total += 1
            res = {}
            for a in assets:
                _, is_, oos = run_split(data[(a, tf)], fn, params)
                res[a] = (is_, oos)
            ok = all(res[a][1].net_return > 0 for a in assets)
            both_oos = np.mean([res[a][1].net_return for a in assets]) * 100
            rows.append((params, res, ok))
            if ok:
                survivors.append((params, res))
        print(f"  {fn.__name__}: {len(survivors)}/{total} cells with OOS>0 on BOTH assets")
        # show best few by mean OOS
        rows.sort(key=lambda r: np.mean([r[1][a][1].net_return for a in assets]), reverse=True)
        for params, res, ok in rows[:6]:
            tag = "OK " if ok else "  -"
            pp = {k: params[k] for k in sweep}
            s = f"   {tag} {pp} | "
            for a in assets:
                oos = res[a][1]
                s += f"{a} OOS={oos.net_return*100:>+6.0f}% (wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f}) "
            print(s)
        return survivors

    zsurv = grid(sig_zfade,
                 dict(tp_mode="mean", max_bars=24),
                 dict(lookback=[20, 40, 60], z=[2.0, 2.5, 3.0], sl_atr=[2.0, 3.0]))
    rsurv = grid(sig_rsi2,
                 dict(period=2, tp_atr=1.0),
                 dict(lo=[5, 10, 15], hi=[85, 90, 95], sl_atr=[2.0, 3.0], max_bars=[6, 12]))

    # -------------------------------------------------------------------
    # PASS 3 — FEE SWEEP on whatever looks least-bad (z-fade z2/mean) to show fee
    # sensitivity (MR is high-frequency: fees are first-order).
    # -------------------------------------------------------------------
    hdr("PASS 3 — FEE SWEEP (z-fade lookback=20 z=2 mean, 1h). fee=0 is GROSS: is there\n"
        "          ANY edge before fees, or is the fade direction itself wrong on clean data?")
    fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]
    base = dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)
    for a in assets:
        df = data[(a, "1h")]
        line = f"  {a}: "
        for f in fees:
            full, is_, oos = run_split(df, sig_zfade, base, fee_rt=f)
            line += f"fee={f*1000:.1f}bp→ full={full.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}%  "
        print(line)

    # -------------------------------------------------------------------
    # PASS 4 — faster TFs (15m, 5m) on the canonical z-fade, to test the "more MR
    # opportunities" hypothesis vs the "fee death" reality.
    # -------------------------------------------------------------------
    hdr("PASS 4 — z-fade across timeframes (lookback=20 z=2 mean). Faster TF = more fees.")
    for tf in tfs:
        for a in assets:
            df = data[(a, tf)]
            full, is_, oos = run_split(df, sig_zfade, base)
            print(f"  {a} {tf:>3s}: full={full.net_return*100:>+7.0f}% IS={is_.net_return*100:>+7.0f}% "
                  f"OOS={oos.net_return*100:>+7.0f}% tr={full.n_trades:>5d} wr={full.win_rate:>4.1f}% "
                  f"shrp={full.sharpe:>+4.1f} mkt={full.time_in_market*100:>3.0f}% €/d={full.daily_profit(2000):>+5.2f}")

    # -------------------------------------------------------------------
    # PASS 5 — SESSION / overnight effect (UTC hour-of-day) on 1h returns.
    # Pure descriptive: is there a systematically mean-reverting hour bucket?
    # -------------------------------------------------------------------
    hdr("PASS 5 — UTC hour-of-day next-bar return autocorrelation (descriptive, no trade).")
    for a in assets:
        df = data[(a, "1h")]
        c = df["close"].values
        ret = pd.Series(np.log(c[1:] / c[:-1]))   # ret[k] = log(c[k+1]/c[k])
        prev = ret.shift(1)
        hours = df["datetime"].dt.hour.values[1:1 + len(ret)]
        tmp = pd.DataFrame({"h": hours[:len(ret)], "r": ret.values, "p": prev.values}).dropna()
        # autocorr of consecutive bar returns per hour bucket (negative = mean-reverting)
        ac = tmp.groupby("h").apply(lambda g: g["r"].corr(g["p"]) if len(g) > 30 else np.nan)
        worst = ac.nsmallest(3)
        best = ac.nlargest(3)
        print(f"  {a}: most mean-reverting UTC hours (neg autocorr): "
              + ", ".join(f"{int(h)}h={v:+.3f}" for h, v in worst.items())
              + " | most trending: "
              + ", ".join(f"{int(h)}h={v:+.3f}" for h, v in best.items()))

    # -------------------------------------------------------------------
    # VERDICT
    # -------------------------------------------------------------------
    hdr("VERDICT")
    n_surv = len(zsurv) + len(rsurv)
    if n_surv == 0:
        print("  No grid cell produced OOS net>0 on BOTH BTC and ETH at baseline fees.")
        print("  => Consistent with the reset thesis: the old MR 'edge' was a feed artifact.")
        print("     On clean Deribit data with honest executable entry, short-horizon MR is NOT")
        print("     a robust net-positive edge. (See per-pass tables above for the evidence.)")
    else:
        print(f"  {n_surv} grid cell(s) survived OOS>0 on both assets. Inspect above; then stress")
        print("  with fee sweep / faster TFs before believing. Surviving configs:")
        for params, res in (zsurv + rsurv):
            ms = np.mean([res[a][1].net_return for a in assets]) * 100
            print(f"    {params}  meanOOS={ms:+.0f}%")
    print(f"\n  (elapsed {time.time()-t0:.0f}s)")


if __name__ == "__main__":
    main()
