"""TRACK F — CALENDAR SEASONALITY on BTC & ETH (hour-of-day, day-of-week, interactions).

Honest test of whether there is a SYSTEMATIC, TRADEABLE calendar edge on the certified
Deribit-mainnet BTC/ETH feeds. Seasonality is the easiest place on earth to overfit
(24 hours x 7 weekdays = 168 buckets => you WILL find "significant" cells by chance), so
every claim here is held to the project's anti-look-ahead, OOS, per-year, both-assets bar.

METHODOLOGY (no shortcuts):
  - ret[i] = close[i]/close[i-1]-1 is known at close[i]. A position decided at close[i]
    earns ret[i+1]. We NEVER include the bar being traded (or any future bar) in the
    statistic that decides the trade.
  - DESCRIPTIVE tables (per-hour / per-weekday mean returns) are split IS(65%)/OOS(35%).
    They are diagnostics, not trades.
  - TRADEABLE rule = ADAPTIVE EXPANDING sign: at close[i] we look up the calendar bucket
    of bar i+1 (the clock is known with zero look-ahead) and take the SIGN of that bucket's
    mean return computed ONLY on bars <= i (expanding, warmup-gated). Long-flat or
    long-short. Fees charged only on |Δposition| (turnover-aware). This lets the data pick
    each bucket's sign LIVE — the honest analogue of "trade the seasonal".
  - Also an in-sample-optimised discrete rule (enter at hour H, hold W bars, best dir) is
    shown ONLY to demonstrate the overfit gap IS->OOS.
  - NET fees fee_side baseline 0.0005 (=0.10% RT); swept 0.0005/0.00075/0.001.
  - A survivor must be net-positive OOS AND across years AND on BOTH BTC & ETH.

Run:  uv run python scripts/research/trackF_seasonality.py
"""
from __future__ import annotations

import sys
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  # noqa: E402

ASSETS = ["BTC", "ETH"]
TF = "1h"
FEE_SIDE = 0.0005          # 0.05%/side = 0.10% round-trip
BARS_PER_DAY = 24
BPY = BARS_PER_DAY * 365.25


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def prep(asset: str, tf: str = TF):
    df = load(asset, tf)
    c = df["close"].values.astype(float)
    ret = np.empty(len(c))
    ret[0] = 0.0
    ret[1:] = c[1:] / c[:-1] - 1.0
    dt = pd.to_datetime(df["datetime"])
    return dict(
        df=df, ret=ret,
        hour=dt.dt.hour.values.astype(int),
        dow=dt.dt.dayofweek.values.astype(int),   # 0=Mon..6=Sun
        ts=dt,
    )


def metrics_from_pnl(pnl: np.ndarray, ts: pd.Series):
    """pnl[i] = realized per-bar net return of the strategy (already fee-adjusted)."""
    eq = np.cumprod(1.0 + np.clip(pnl, -0.99, None))
    r = pnl[np.isfinite(pnl)]
    sharpe = float(np.mean(r) / np.std(r) * np.sqrt(BPY)) if np.std(r) > 0 else 0.0
    peak = np.maximum.accumulate(eq)
    maxdd = float(np.max((peak - eq) / peak)) if len(eq) else 0.0
    span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
    years = span_days / 365.25 if span_days > 0 else 1.0
    total = eq[-1] / eq[0] if len(eq) else 1.0
    cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
    daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0
    return dict(sharpe=sharpe, maxdd=maxdd, cagr=cagr, total=total - 1.0,
                daily_2k=daily_2k, eq=eq)


def per_year_pnl(pnl: np.ndarray, ts: pd.Series):
    s = pd.Series(pnl, index=ts.values)
    out = {}
    for y, g in s.groupby(s.index.year):
        eq = np.cumprod(1.0 + np.clip(g.values, -0.99, None))
        out[int(y)] = float(eq[-1] - 1.0)
    return out


# ---------------------------------------------------------------------------
# 1. DESCRIPTIVE seasonality tables (diagnostics, IS vs OOS)
# ---------------------------------------------------------------------------
def descriptive(data, frac=0.65):
    n = len(data["ret"])
    cut = int(n * frac)
    ret, hour, dow = data["ret"], data["hour"], data["dow"]
    rows_h, rows_d = {}, {}
    for h in range(24):
        m_is = ret[:cut][hour[:cut] == h]
        m_oos = ret[cut:][hour[cut:] == h]
        rows_h[h] = (m_is.mean() * 1e4, m_oos.mean() * 1e4,
                     np.sign(m_is.mean()) == np.sign(m_oos.mean()))
    for d in range(7):
        m_is = ret[:cut][dow[:cut] == d]
        m_oos = ret[cut:][dow[cut:] == d]
        rows_d[d] = (m_is.mean() * 1e4, m_oos.mean() * 1e4,
                     np.sign(m_is.mean()) == np.sign(m_oos.mean()))
    return rows_h, rows_d


# ---------------------------------------------------------------------------
# 2. ADAPTIVE EXPANDING-sign seasonal strategy (the honest tradeable test)
# ---------------------------------------------------------------------------
def adaptive_seasonal(data, bucket="hour", mode="longshort",
                      warmup=200, fee_side=FEE_SIDE):
    """Position at close[i] = sign of the EXPANDING past mean return of bar (i+1)'s
    calendar bucket, using only bars <= i. earns ret[i+1]. Fee on |Δposition|."""
    ret = data["ret"]
    key = data[bucket]
    n = len(ret)
    nbuck = int(key.max()) + 1
    sums = np.zeros(nbuck)
    counts = np.zeros(nbuck)
    pos = np.zeros(n)
    for i in range(1, n - 1):
        b = key[i]
        sums[b] += ret[i]
        counts[b] += 1
        nb = key[i + 1]
        if counts[nb] >= warmup:
            m = sums[nb] / counts[nb]
            if m > 0:
                pos[i] = 1.0
            else:
                pos[i] = -1.0 if mode == "longshort" else 0.0
    # pnl[i] earned over bar i+1
    pnl = np.zeros(n)
    prev = 0.0
    for i in range(1, n - 1):
        turn = abs(pos[i] - prev)
        pnl[i] = pos[i] * ret[i + 1] - fee_side * turn
        prev = pos[i]
    return pnl, pos


def adaptive_hourxdow(data, mode="longshort", warmup=120, fee_side=FEE_SIDE):
    ret, hour, dow = data["ret"], data["hour"], data["dow"]
    key = hour * 7 + dow                      # 168 buckets
    n = len(ret)
    sums = np.zeros(168)
    counts = np.zeros(168)
    pos = np.zeros(n)
    for i in range(1, n - 1):
        b = key[i]
        sums[b] += ret[i]
        counts[b] += 1
        nb = key[i + 1]
        if counts[nb] >= warmup:
            m = sums[nb] / counts[nb]
            if m > 0:
                pos[i] = 1.0
            else:
                pos[i] = -1.0 if mode == "longshort" else 0.0
    pnl = np.zeros(n)
    prev = 0.0
    for i in range(1, n - 1):
        turn = abs(pos[i] - prev)
        pnl[i] = pos[i] * ret[i + 1] - fee_side * turn
        prev = pos[i]
    return pnl, pos


# ---------------------------------------------------------------------------
# 3. In-sample-optimised DISCRETE rule (to expose the overfit gap)
# ---------------------------------------------------------------------------
def discrete_hour_rule_scan(data, frac=0.65, fee_side=FEE_SIDE):
    """Scan IS for best (entry_hour, hold_window, direction) by IS Sharpe; report OOS.

    A trade: enter at close of bar whose hour==H (decided with data<=close[i]), hold W
    bars, exit at close. One trade per day. Fee charged round-trip on each trade.
    """
    ret, hour, ts = data["ret"], data["hour"], data["ts"]
    n = len(ret)
    cut = int(n * frac)

    def rule_pnl(H, W, direction, lo, hi):
        pnl = np.zeros(n)
        i = lo
        last_exit = lo - 1
        while i < hi:
            if hour[i] == H and i > last_exit:
                # cumulative return over the next W bars: prod(1+ret[i+1..i+W]) - 1
                end = min(i + W, n - 1)
                gross = np.prod(1.0 + ret[i + 1:end + 1]) - 1.0
                pnl[i] = direction * gross - 2 * fee_side
                last_exit = end
                i = end
            else:
                i += 1
        return pnl

    best = None
    n_tested = 0
    for H in range(24):
        for W in (1, 2, 3, 4, 6, 8, 12, 24):
            for direction in (+1, -1):
                n_tested += 1
                pnl_is = rule_pnl(H, W, direction, 1, cut)
                r = pnl_is[pnl_is != 0.0]
                if len(r) < 50:
                    continue
                sh = np.mean(r) / np.std(r) * np.sqrt(BPY) if np.std(r) > 0 else 0.0
                if best is None or sh > best[0]:
                    best = (sh, H, W, direction)
    sh, H, W, direction = best
    pnl_oos = rule_pnl(H, W, direction, cut, n)
    r_oos = pnl_oos[pnl_oos != 0.0]
    sh_oos = (np.mean(r_oos) / np.std(r_oos) * np.sqrt(BPY)) if (len(r_oos) and np.std(r_oos) > 0) else 0.0
    return dict(n_tested=n_tested, H=H, W=W, dir=direction, sh_is=sh,
                sh_oos=sh_oos, n_is=int((rule_pnl(H, W, direction, 1, cut) != 0).sum()),
                n_oos=len(r_oos), oos_mean_bp=r_oos.mean() * 1e4 if len(r_oos) else 0.0)


# ---------------------------------------------------------------------------
# reporting
# ---------------------------------------------------------------------------
def split_metrics(pnl, ts, frac=0.65):
    n = len(pnl)
    cut = int(n * frac)
    m_is = metrics_from_pnl(pnl[:cut], ts.iloc[:cut])
    m_oos = metrics_from_pnl(pnl[cut:], ts.iloc[cut:])
    m_all = metrics_from_pnl(pnl, ts)
    return m_is, m_oos, m_all


def turnover_per_year(pos, ts):
    s = pd.Series(np.abs(np.diff(pos, prepend=0.0)), index=ts.values)
    return s.groupby(s.index.year).sum().to_dict()


def main():
    print("=" * 100)
    print("# TRACK F — CALENDAR SEASONALITY (hour-of-day / day-of-week / hour×weekday)")
    print("# certified Deribit-mainnet BTC & ETH, 1h UTC. fee_side=0.0005 (0.10% RT).")
    print("# No look-ahead: bucket stats use only bars <= i; position earns ret[i+1].")
    print("=" * 100)

    data = {a: prep(a) for a in ASSETS}

    # --- DESCRIPTIVE ---------------------------------------------------------
    print("\n" + "#" * 100)
    print("# 1. DESCRIPTIVE per-bucket mean returns (basis points/bar). IS=first 65%, OOS=last 35%.")
    print("#    'sign?' = IS and OOS agree on sign. Diagnostics only (NOT trades, no fees).")
    print("#" * 100)
    for a in ASSETS:
        rows_h, rows_d = descriptive(data[a])
        print(f"\n  ── {a}  HOUR-OF-DAY (UTC)  mean bp/hr ─────────────────────────────")
        print("     hr :  IS_bp   OOS_bp  sign?")
        agree_h = 0
        for h in range(24):
            iv, ov, ag = rows_h[h]
            agree_h += int(ag)
            flag = "  <-- US open" if h in (13, 14) else ("  <-- US close" if h in (20, 21) else "")
            print(f"     {h:>2d} : {iv:>+6.2f}  {ov:>+6.2f}   {'Y' if ag else '.'}{flag}")
        print(f"     hour sign-agreement IS/OOS: {agree_h}/24")
        print(f"\n  ── {a}  DAY-OF-WEEK  mean bp/bar (0=Mon..6=Sun) ──────────────────")
        names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
        agree_d = 0
        for d in range(7):
            iv, ov, ag = rows_d[d]
            agree_d += int(ag)
            print(f"     {names[d]} : {iv:>+6.3f}  {ov:>+6.3f}   {'Y' if ag else '.'}")
        print(f"     weekday sign-agreement IS/OOS: {agree_d}/7")

    # --- ADAPTIVE EXPANDING-SIGN (the honest tradeable test) ----------------
    print("\n" + "#" * 100)
    print("# 2. ADAPTIVE EXPANDING-SIGN seasonal strategies (HONEST tradeable test).")
    print("#    sign of bucket's PAST-ONLY mean decides position; fee on turnover.")
    print("#" * 100)
    configs = [
        ("HOUR  long-short", "hour", "longshort", 200),
        ("HOUR  long-flat ", "hour", "longflat", 200),
        ("DOW   long-short", "dow", "longshort", 60),
        ("DOW   long-flat ", "dow", "longflat", 60),
    ]
    for label, bucket, mode, warmup in configs:
        print(f"\n  ── {label} ────────────────────────────────────────────────────")
        for a in ASSETS:
            pnl, pos = adaptive_seasonal(data[a], bucket=bucket, mode=mode, warmup=warmup)
            ts = data[a]["ts"]
            m_is, m_oos, m_all = split_metrics(pnl, ts)
            py = per_year_pnl(pnl, ts)
            yrs = "".join(f"{py.get(y, float('nan'))*100:>+6.0f}" for y in range(2019, 2027))
            print(f"     {a}: ALL Sh={m_all['sharpe']:>+5.2f} CAGR={m_all['cagr']*100:>+6.1f}% "
                  f"DD={m_all['maxdd']*100:>4.1f}% €/d={m_all['daily_2k']:>+5.2f} | "
                  f"IS Sh={m_is['sharpe']:>+5.2f}  OOS Sh={m_oos['sharpe']:>+5.2f}")
            print(f"        per-year %: {yrs}   (2019..2026)")

    # buy-and-hold benchmark — the key control: does any 'seasonal' beat just being long?
    print(f"\n  ── BUY-AND-HOLD benchmark (the control for long-bias) ──")
    for a in ASSETS:
        ret = data[a]["ret"].copy()
        ret[0] = 0.0
        m = metrics_from_pnl(ret, data[a]["ts"])
        print(f"     {a}: Sh={m['sharpe']:>+5.2f} CAGR={m['cagr']*100:>+6.1f}% DD={m['maxdd']*100:>4.1f}% "
              f"  <- compare to DOW long-flat above (it's nearly identical = no edge, just long)")

    # hour x weekday interaction (168 buckets — extreme overfit risk)
    print(f"\n  ── HOUR×WEEKDAY long-short (168 buckets, warmup 120) — overfit canary ──")
    for a in ASSETS:
        pnl, pos = adaptive_hourxdow(data[a], mode="longshort", warmup=120)
        ts = data[a]["ts"]
        m_is, m_oos, m_all = split_metrics(pnl, ts)
        print(f"     {a}: ALL Sh={m_all['sharpe']:>+5.2f} CAGR={m_all['cagr']*100:>+6.1f}% "
              f"DD={m_all['maxdd']*100:>4.1f}% | IS Sh={m_is['sharpe']:>+5.2f}  OOS Sh={m_oos['sharpe']:>+5.2f}")

    # --- FEE SWEEP on the best adaptive config -------------------------------
    print("\n" + "#" * 100)
    print("# 3. FEE SWEEP — HOUR long-short adaptive (turnover-aware). Are survivors fee-robust?")
    print("#" * 100)
    for fee in (0.0, 0.0005, 0.00075, 0.001):
        line = f"  fee_side={fee:.5f} (RT {fee*2*100:.2f}%): "
        for a in ASSETS:
            pnl, _ = adaptive_seasonal(data[a], bucket="hour", mode="longshort",
                                       warmup=200, fee_side=fee)
            m = metrics_from_pnl(pnl, data[a]["ts"])
            line += f"{a} Sh={m['sharpe']:>+5.2f} CAGR={m['cagr']*100:>+6.1f}%   "
        print(line)

    # --- TURNOVER (fees are first-order for hour strategies) -----------------
    print("\n" + "#" * 100)
    print("# 4. TURNOVER (HOUR long-short adaptive): position flips/year (each flip costs ~fee).")
    print("#" * 100)
    for a in ASSETS:
        _, pos = adaptive_seasonal(data[a], bucket="hour", mode="longshort", warmup=200)
        tpy = turnover_per_year(pos, data[a]["ts"])
        s = "  ".join(f"{y}:{int(v)}" for y, v in sorted(tpy.items()))
        print(f"     {a} turnover units/yr: {s}")

    # --- IN-SAMPLE-OPTIMISED DISCRETE RULE (overfit demonstration) ----------
    print("\n" + "#" * 100)
    print("# 5. IN-SAMPLE-OPTIMISED discrete rule (enter hour H, hold W, best dir).")
    print("#    Picked by IS Sharpe, reported OOS. Demonstrates the multiple-testing trap.")
    print("#" * 100)
    for a in ASSETS:
        r = discrete_hour_rule_scan(data[a])
        print(f"     {a}: tested {r['n_tested']} (H,W,dir) cells -> best IS "
              f"H={r['H']:02d} hold={r['W']}h dir={r['dir']:+d}  "
              f"IS Sh={r['sh_is']:>+5.2f} (n={r['n_is']})  ->  OOS Sh={r['sh_oos']:>+5.2f} "
              f"(n={r['n_oos']}, mean {r['oos_mean_bp']:>+.1f} bp/trade)")

    # --- VERDICT -------------------------------------------------------------
    print("\n" + "#" * 100)
    print("# MULTIPLE-TESTING CAVEAT")
    print("#" * 100)
    print("""
  Buckets examined: 24 hours + 7 weekdays + 168 hour×weekday = 199 calendar cells PER ASSET,
  each tested IS and OOS, plus discrete grid = 24×8×2 = 384 (H,W,dir) cells per asset.
  With that many cells, spurious 'significant' buckets are GUARANTEED. The honest filters
  applied here: (a) adaptive sign chosen live on PAST data only (no cherry-picking),
  (b) must hold OOS, (c) must hold per-year, (d) must hold on BOTH BTC AND ETH.
  Read the IS->OOS Sharpe collapse and the per-year sign flips above as the real verdict.
""")


if __name__ == "__main__":
    main()
