"""TRACK H — VOLUME, RANGE & VOLATILITY-REGIME signals on CLEAN BTC/ETH (Deribit mainnet).

The single question: net of realistic Deribit fees, OOS-robust on BOTH BTC & ETH, on >=12h
timeframes (the only honest regime — sub-12h is fees + HF-noise overfit + the open-label
look-ahead trap), is there ANY volume / range / volatility-regime signal that is

   (a) net-positive OOS on both assets standalone, AND
   (b) uncorrelated (|corr| < ~0.3) to the deployed winner TP01, AND/OR
   (c) usable as a REGIME FILTER that lifts TP01's Sharpe above ~1.32 or cuts its DD?

HONESTY / NO LOOK-AHEAD:
  * Everything runs on the SAME causal per-bar engine used by TP01 (net_returns): we build a
    continuous TARGET position decided with data <= close[i], then HOLD it during bar i+1
    (pos_held[t] = target[t-1]). Gross = pos_held * simple_return[t]; fee charged on |Δpos|.
    This is identical in spirit to the harness `backtest_signals` (decide<=close[i], fill at
    close[i]); we cross-check two discrete signals through `backtest_signals` too.
  * Volume / range / vol features for bar i use ONLY bars <= i (rolling, prior-window, shift).
  * 12h / 1d frames are resampled from the certified 1h feed via resample_tf (label='left',
    closed='left') and consumed index-based with the +1 bar hold -> the open-label is never
    leaked (verified in trackD_lookahead_audit.py: Sharpe is label-invariant under this hold).

Run:
    uv run python scripts/research/trackH_volume_vol.py            # full (12h + 1d)
    uv run python scripts/research/trackH_volume_vol.py --quick    # 12h only, fewer grids
"""
from __future__ import annotations

import argparse
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, backtest_signals
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_tf

ASSETS = ["BTC", "ETH"]
FEE_SIDE = 0.0005          # 0.05%/side = 0.10% RT (Deribit taker)
OOS_FRAC = 0.65
TF_BPD = {"12h": 2, "1d": 1}


# ===========================================================================
# Causal feature helpers (all use data <= i)
# ===========================================================================
def simple_returns(c: np.ndarray) -> np.ndarray:
    r = np.zeros(len(c))
    r[1:] = c[1:] / c[:-1] - 1.0
    return r


def realized_vol(r: np.ndarray, win: int, bpy: float) -> np.ndarray:
    return pd.Series(r).rolling(win, min_periods=max(2, win // 2)).std().values * np.sqrt(bpy)


def roll_max_prior(x: np.ndarray, win: int) -> np.ndarray:
    """Max over the PRIOR `win` bars (excludes current bar i)."""
    return pd.Series(x).shift(1).rolling(win, min_periods=win).max().values


def roll_min_prior(x: np.ndarray, win: int) -> np.ndarray:
    return pd.Series(x).shift(1).rolling(win, min_periods=win).min().values


def roll_mean_prior(x: np.ndarray, win: int) -> np.ndarray:
    return pd.Series(x).shift(1).rolling(win, min_periods=win).mean().values


def vol_zscore(vol: np.ndarray, win: int) -> np.ndarray:
    """z-score of current volume vs PRIOR `win` bars (uses <= i)."""
    s = pd.Series(vol)
    m = s.shift(1).rolling(win, min_periods=win).mean()
    sd = s.shift(1).rolling(win, min_periods=win).std()
    return ((s - m) / sd).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


# ===========================================================================
# Per-bar net-returns engine (causal, fee on turnover) — identical to TP01.net_returns
# ===========================================================================
def net_from_target(target: np.ndarray, r: np.ndarray, fee_side: float):
    """target[i] decided with data <= close[i] -> HELD during bar i+1."""
    target = np.nan_to_num(target, nan=0.0)
    pos = np.zeros(len(target))
    pos[1:] = target[:-1]
    gross = pos * r
    turn = np.abs(np.diff(pos, prepend=0.0))
    net = gross - fee_side * turn
    net[0] = 0.0
    net = np.clip(net, -0.99, None)
    return net, pos, turn


def metrics(net: np.ndarray, idx: pd.DatetimeIndex, turn: np.ndarray, bpy: float) -> dict:
    rr = net[np.isfinite(net)]
    sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
    equity = np.cumprod(1.0 + np.clip(net, -0.99, None))
    peak = np.maximum.accumulate(equity)
    dd = float(np.max((peak - equity) / peak)) if len(equity) else 0.0
    span_days = (idx[-1] - idx[0]).total_seconds() / 86400
    years = span_days / 365.25 if span_days > 0 else 1.0
    total = equity[-1] / equity[0] if len(equity) else 1.0
    cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
    ann_turn = float(np.sum(turn)) / years if years > 0 else 0.0
    return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
                ann_turnover=ann_turn, equity=equity, years=years)


def per_year(net: np.ndarray, idx: pd.DatetimeIndex) -> dict:
    eq = pd.Series(np.cumprod(1.0 + np.clip(net, -0.99, None)), index=idx)
    out = {}
    for y, g in eq.groupby(eq.index.year):
        if len(g) > 1 and g.iloc[0] > 0:
            out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
    return out


# ===========================================================================
# SIGNALS — each returns a continuous TARGET array (frac of equity, +/-), causal.
# ===========================================================================
def sig_vt_long(df, bpd, target_vol=0.20, vol_win_days=30, lev=2.0, **_):
    """Volatility-managed LONG: always long, sized to a vol target (no trend at all).
    Tests Moreira-Muir 'volatility-managed' alpha vs plain buy-and-hold."""
    c = df["close"].values.astype(float)
    r = simple_returns(c)
    bpy = bpd * 365.25
    vol = realized_vol(r, vol_win_days * bpd, bpy)
    tgt = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
    return np.clip(tgt, 0, lev)


def sig_vol_breakout(df, bpd, don=20, zwin=20, zk=1.0, long_short=False, **_):
    """Volume-confirmed Donchian breakout (continuation). Long when close > prior-`don`-bar high
    AND volume z-score > zk; stay long until close < prior-`don`-bar low (then flat/short)."""
    c = df["close"].values.astype(float)
    h = df["high"].values.astype(float)
    l = df["low"].values.astype(float)
    vol = df["volume"].values.astype(float)
    hi = roll_max_prior(h, don)
    lo = roll_min_prior(l, don)
    z = vol_zscore(vol, zwin)
    up = (c > hi) & (z > zk)
    dn = (c < lo) & (z > zk)
    state = np.zeros(len(c))
    s = 0.0
    for i in range(len(c)):
        if up[i]:
            s = 1.0
        elif dn[i]:
            s = -1.0 if long_short else 0.0
        elif s == 1.0 and c[i] < lo[i]:   # trailing exit for longs
            s = -1.0 if long_short else 0.0
        elif s == -1.0 and c[i] > hi[i]:
            s = 1.0
        state[i] = s
    return state


def sig_obv_trend(df, bpd, ma=30, long_short=False, **_):
    """OBV trend: OBV = cumsum(sign(ret)*volume); long when OBV > its EMA(ma), else flat/short."""
    c = df["close"].values.astype(float)
    vol = df["volume"].values.astype(float)
    r = simple_returns(c)
    obv = np.cumsum(np.sign(r) * vol)
    ema = pd.Series(obv).ewm(span=ma, adjust=False).mean().values
    d = np.where(obv > ema, 1.0, (-1.0 if long_short else 0.0))
    return d


def sig_vw_momentum(df, bpd, mom_win=30, vol_win_days=30, target_vol=0.20, lev=2.0,
                    long_only=True, **_):
    """Volume-weighted momentum: sign of volume-weighted mean return over `mom_win` bars,
    vol-targeted. Compare to plain TSMOM (does weighting by volume add anything?)."""
    c = df["close"].values.astype(float)
    vol = df["volume"].values.astype(float)
    r = simple_returns(c)
    rw = r * vol
    num = pd.Series(rw).rolling(mom_win, min_periods=mom_win).sum().values
    den = pd.Series(vol).rolling(mom_win, min_periods=mom_win).sum().values
    vwret = np.where(den > 0, num / den, 0.0)
    direction = np.sign(vwret)
    if long_only:
        direction = np.clip(direction, 0, None)
    bpy = bpd * 365.25
    rv = realized_vol(r, vol_win_days * bpd, bpy)
    scal = np.where((rv > 0) & np.isfinite(rv), target_vol / rv, 0.0)
    return np.clip(direction * scal, -lev, lev)


def sig_range_expansion(df, bpd, rng_win=20, k=1.5, hold=5, long_short=False, **_):
    """Range-expansion breakout: when today's range > k * avg(prior `rng_win` ranges) and the
    bar closed in the upper/lower half, go with the close direction; hold `hold` bars."""
    c = df["close"].values.astype(float)
    h = df["high"].values.astype(float)
    l = df["low"].values.astype(float)
    rng = h - l
    avg = roll_mean_prior(rng, rng_win)
    expand = rng > k * avg
    pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
    long_trig = expand & (pos_in_bar > 0.6)
    short_trig = expand & (pos_in_bar < 0.4)
    state = np.zeros(len(c))
    hold_left = 0
    cur = 0.0
    for i in range(len(c)):
        if hold_left > 0:
            hold_left -= 1
        else:
            cur = 0.0
        if long_trig[i]:
            cur = 1.0
            hold_left = hold
        elif short_trig[i] and long_short:
            cur = -1.0
            hold_left = hold
        state[i] = cur
    return state


def sig_nr_breakout(df, bpd, nr=7, hold=5, long_short=False, **_):
    """NR-N breakout (daily-style): when the current bar's range is the narrowest of the last
    `nr` bars, take the breakout of the prior bar's high/low on the next bars; hold `hold`."""
    c = df["close"].values.astype(float)
    h = df["high"].values.astype(float)
    l = df["low"].values.astype(float)
    rng = h - l
    is_nr = pd.Series(rng).rolling(nr, min_periods=nr).apply(
        lambda w: 1.0 if w[-1] == np.min(w) else 0.0, raw=True).values
    state = np.zeros(len(c))
    cur = 0.0
    hold_left = 0
    armed = False
    arm_hi = arm_lo = np.nan
    for i in range(len(c)):
        if hold_left > 0:
            hold_left -= 1
        else:
            cur = 0.0
        if armed:
            if c[i] > arm_hi:
                cur = 1.0
                hold_left = hold
                armed = False
            elif c[i] < arm_lo and long_short:
                cur = -1.0
                hold_left = hold
                armed = False
        if is_nr[i] == 1.0:
            armed = True
            arm_hi = h[i]
            arm_lo = l[i]
        state[i] = cur
    return state


def sig_decl_vol_reversal(df, bpd, mom_win=10, vwin=10, **_):
    """Declining-volume reversal (fade): after an up-move on DECLINING volume, fade (short);
    after a down-move on declining volume, go long. Pure contrarian, vol-confirmed exhaustion."""
    c = df["close"].values.astype(float)
    vol = df["volume"].values.astype(float)
    ret = pd.Series(c).pct_change(mom_win).values
    vtrend = vol - roll_mean_prior(vol, vwin)
    declining = vtrend < 0
    state = np.zeros(len(c))
    state[(ret > 0) & declining] = -1.0
    state[(ret < 0) & declining] = 1.0
    return state


SIGNALS = {
    "VT-long":        (sig_vt_long, dict(target_vol=0.20, vol_win_days=30, lev=2.0)),
    "VolBreakout":    (sig_vol_breakout, dict(don=20, zwin=20, zk=1.0)),
    "OBV-trend":      (sig_obv_trend, dict(ma=30)),
    "VW-mom":         (sig_vw_momentum, dict(mom_win=30, vol_win_days=30)),
    "RangeExpand":    (sig_range_expansion, dict(rng_win=20, k=1.5, hold=5)),
    "NR7-break":      (sig_nr_breakout, dict(nr=7, hold=5)),
    "DeclVolRev":     (sig_decl_vol_reversal, dict(mom_win=10, vwin=10)),
}


# ===========================================================================
# Evaluation
# ===========================================================================
def eval_signal(fn, params, tf, asset, fee_side=FEE_SIDE):
    df = resample_tf(load(asset, "1h"), tf)
    bpd = TF_BPD[tf]
    bpy = bpd * 365.25
    c = df["close"].values.astype(float)
    r = simple_returns(c)
    idx = pd.to_datetime(df["datetime"].values)
    tgt = fn(df, bpd, **params)
    net, pos, turn = net_from_target(tgt, r, fee_side)
    m = metrics(net, idx, turn, bpy)
    # OOS split
    cut = int(len(net) * OOS_FRAC)
    mi = metrics(net[:cut], idx[:cut], turn[:cut], bpy)
    mo = metrics(net[cut:], idx[cut:], turn[cut:], bpy)
    return dict(net=net, idx=idx, full=m, is_=mi, oos=mo, py=per_year(net, idx))


def tp01_net(asset, tf):
    tp = TrendPortfolio(**CANONICAL)
    df = resample_tf(load(asset, "1h"), tf)
    net, ts = tp.net_returns(df)
    return pd.Series(net, index=pd.to_datetime(ts.values))


def corr_to_tp01(net, idx, tp_series):
    s = pd.Series(net, index=idx)
    j = pd.concat([s.rename("a"), tp_series.rename("b")], axis=1, join="inner").fillna(0.0)
    if j["a"].std() == 0 or j["b"].std() == 0:
        return 0.0
    return float(j["a"].corr(j["b"]))


# ===========================================================================
# Reports
# ===========================================================================
def report_headline(tf, quick):
    print("\n" + "=" * 120)
    print(f"# HEADLINE — TF {tf} | standalone signals, full / IS / OOS, turnover, corr→TP01 (fee 0.10% RT)")
    print("=" * 120)
    tp = {a: tp01_net(a, tf) for a in ASSETS}
    print(f"  {'signal':<14s}{'asset':<6s}"
          f"{'fullShrp':>9s}{'fullCAGR':>9s}{'fullDD':>7s}"
          f"{'IS_Shrp':>8s}{'OOS_Shrp':>9s}{'OOS_ret':>8s}{'turn/y':>8s}{'corrTP':>8s}")
    results = {}
    for name, (fn, params) in SIGNALS.items():
        for a in ASSETS:
            res = eval_signal(fn, params, tf, a)
            cr = corr_to_tp01(res["net"], res["idx"], tp[a])
            results[(name, a)] = (res, cr)
            print(f"  {name:<14s}{a:<6s}"
                  f"{res['full']['sharpe']:>9.2f}{res['full']['cagr']*100:>8.1f}%"
                  f"{res['full']['max_dd']*100:>6.1f}%"
                  f"{res['is_']['sharpe']:>8.2f}{res['oos']['sharpe']:>9.2f}"
                  f"{res['oos']['total']*100:>7.1f}%{res['full']['ann_turnover']:>8.1f}{cr:>8.2f}")
    return results, tp


def report_peryear(results):
    print("\n" + "-" * 120)
    print("# PER-YEAR net return (%) — only signals with OOS Sharpe>0 on BOTH assets shown")
    print("-" * 120)
    years = list(range(2018, 2027))
    # which signals pass OOS>0 both assets
    good = []
    for name in SIGNALS:
        if all(results[(name, a)][0]["oos"]["sharpe"] > 0 for a in ASSETS):
            good.append(name)
    if not good:
        print("  (none — no signal has positive OOS Sharpe on BOTH assets)")
        return good
    print("  " + " " * 22 + "".join(f"{y:>7d}" for y in years))
    for name in good:
        for a in ASSETS:
            py = results[(name, a)][0]["py"]
            row = "".join(("    .  " if y not in py else f"{py[y]*100:>+7.0f}") for y in years)
            print(f"  {name+' '+a:<22s}{row}")
    return good


def report_grid(quick):
    print("\n" + "=" * 120)
    print("# GRID ROBUSTNESS (TF 12h) — fraction of cells with positive full+OOS Sharpe on BOTH assets")
    print("=" * 120)
    tf = "12h"
    grids = {
        "VolBreakout": ("sig", sig_vol_breakout,
                        dict(don=[10, 20, 40] if not quick else [20],
                             zwin=[10, 20, 40], zk=[0.5, 1.0, 2.0])),
        "OBV-trend":   ("sig", sig_obv_trend, dict(ma=[15, 30, 60, 100])),
        "VW-mom":      ("sig", sig_vw_momentum,
                        dict(mom_win=[15, 30, 60, 90], long_only=[True])),
        "RangeExpand": ("sig", sig_range_expansion,
                        dict(rng_win=[10, 20, 40], k=[1.3, 1.5, 2.0], hold=[3, 5, 10])),
        "VT-long":     ("sig", sig_vt_long, dict(target_vol=[0.15, 0.20, 0.30],
                                                 vol_win_days=[15, 30, 60])),
    }
    from itertools import product
    for name, (_, fn, axes) in grids.items():
        keys = list(axes.keys())
        combos = list(product(*[axes[k] for k in keys]))
        npos = 0
        best = (-9, None)
        for combo in combos:
            params = dict(zip(keys, combo))
            ok = True
            sh_sum = 0.0
            for a in ASSETS:
                res = eval_signal(fn, params, tf, a)
                if not (res["full"]["sharpe"] > 0 and res["oos"]["sharpe"] > 0):
                    ok = False
                sh_sum += res["oos"]["sharpe"]
            if ok:
                npos += 1
            if sh_sum > best[0]:
                best = (sh_sum, params)
        print(f"  {name:<14s} positive both(full&OOS): {npos:>3d}/{len(combos):<3d} "
              f"({npos/len(combos)*100:>4.0f}%)   best OOS-sum cfg: {best[1]}")


def report_feesweep():
    print("\n" + "=" * 120)
    print("# FEE SWEEP (TF 12h) — OOS Sharpe (BTC/ETH) vs round-trip fee for the headline signals")
    print("=" * 120)
    tf = "12h"
    fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]   # per side; RT = 2x
    print(f"  {'signal':<14s}" + "".join(f"  RT{f*2*100:>4.2f}%" for f in fees))
    for name, (fn, params) in SIGNALS.items():
        cells = []
        for f in fees:
            shs = []
            for a in ASSETS:
                res = eval_signal(fn, params, tf, a, fee_side=f)
                shs.append(res["oos"]["sharpe"])
            cells.append(f"{shs[0]:>4.2f}/{shs[1]:<4.2f}")
        print(f"  {name:<14s}" + "".join(f"  {c:>9s}" for c in cells))


# ===========================================================================
# REGIME FILTER on TP01 — does a vol/volume regime mask lift Sharpe or cut DD?
# ===========================================================================
def vol_regime_mask(df, bpd, win_days=30, mode="low", q=0.5):
    """Boolean per-bar mask (decided <= close[i]) for a realized-vol regime.
    mode='low': keep exposure when vol <= rolling median; 'high': when vol > median."""
    c = df["close"].values.astype(float)
    r = simple_returns(c)
    bpy = bpd * 365.25
    vol = realized_vol(r, win_days * bpd, bpy)
    # causal expanding/rolling quantile threshold (use a long rolling window, prior bars)
    thr = pd.Series(vol).shift(1).rolling(180 * bpd, min_periods=30 * bpd).quantile(q).values
    if mode == "low":
        mask = vol <= thr
    else:
        mask = vol > thr
    return np.nan_to_num(mask.astype(float), nan=1.0)   # default keep before warmup


def vol_managed_mask(df, bpd, win_days=30, target_vol=0.20, cap=1.5):
    """Continuous vol-scaling multiplier on TP01: scale exposure by target_vol/realized_vol,
    capped — an explicit volatility-managed overlay distinct from TP01's own sizing."""
    c = df["close"].values.astype(float)
    r = simple_returns(c)
    bpy = bpd * 365.25
    vol = realized_vol(r, win_days * bpd, bpy)
    mult = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 1.0)
    return np.clip(mult, 0.0, cap)


def report_regime_filter(tf="12h"):
    print("\n" + "=" * 120)
    print(f"# REGIME FILTER on TP01 (TF {tf}) — apply a vol mask/overlay to TP01 target, 50/50 portfolio")
    print("=" * 120)
    bpd = TF_BPD[tf]
    bpy = bpd * 365.25
    tp = TrendPortfolio(**CANONICAL)

    def portfolio(transform):
        """transform(df,target)->target'; returns combined 50/50 net series + idx."""
        series = {}
        for a in ASSETS:
            df = resample_tf(load(a, "1h"), tf)
            r = simple_returns(df["close"].values.astype(float))
            tgt = tp.target_series(df)
            tgt2 = transform(df, tgt)
            net, _, _ = net_from_target(tgt2, r, CANONICAL["fee_side"])
            series[a] = pd.Series(net, index=pd.to_datetime(df["datetime"].values))
        J = pd.concat(series, axis=1, join="inner").fillna(0.0)
        combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values
        return combo, J.index

    variants = {
        "TP01 baseline":       lambda df, t: t,
        "× keep LOW-vol":      lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.5),
        "× keep HIGH-vol":     lambda df, t: t * vol_regime_mask(df, bpd, mode="high", q=0.5),
        "× keep LOW-vol q.7":  lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.7),
        "× vol-managed x1.5":  lambda df, t: t * vol_managed_mask(df, bpd, cap=1.5) /
                                            np.maximum(vol_managed_mask(df, bpd, cap=1.5).mean(), 1e-9),
        "× obv-up only":       lambda df, t: t * (np.where(
            np.cumsum(np.sign(simple_returns(df['close'].values.astype(float))) * df['volume'].values)
            > pd.Series(np.cumsum(np.sign(simple_returns(df['close'].values.astype(float)))
                                  * df['volume'].values)).ewm(span=30, adjust=False).mean().values,
            1.0, 0.0)),
    }
    print(f"  {'variant':<22s}{'fullShrp':>9s}{'IS_Shrp':>8s}{'OOS_Shrp':>9s}"
          f"{'CAGR':>8s}{'maxDD':>8s}{'turn/y':>9s}")
    for name, tr in variants.items():
        combo, idx = portfolio(tr)
        m = metrics(combo, idx, np.zeros_like(combo), bpy)
        cut = int(len(combo) * OOS_FRAC)
        mi = metrics(combo[:cut], idx[:cut], np.zeros_like(combo[:cut]), bpy)
        mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
        tt = 0.0
        for a in ASSETS:
            df = resample_tf(load(a, "1h"), tf)
            tgt2 = tr(df, tp.target_series(df))
            tt += np.sum(np.abs(np.diff(np.nan_to_num(tgt2), prepend=0.0)))
        ann_tt = tt / m["years"] / 2.0
        print(f"  {name:<22s}{m['sharpe']:>9.2f}{mi['sharpe']:>8.2f}{mo['sharpe']:>9.2f}"
              f"{m['cagr']*100:>7.1f}%{m['max_dd']*100:>7.1f}%{ann_tt:>9.1f}")

    # robustness of the OBV-up filter across EMA spans (is 1.49 luck or stable?)
    print("\n  OBV-up filter robustness across EMA span (full / OOS Sharpe, maxDD):")
    for span in [15, 20, 30, 45, 60, 90]:
        def tr(df, t, sp=span):
            c = df['close'].values.astype(float)
            v = df['volume'].values.astype(float)
            obv = np.cumsum(np.sign(simple_returns(c)) * v)
            ema = pd.Series(obv).ewm(span=sp, adjust=False).mean().values
            return t * np.where(obv > ema, 1.0, 0.0)
        combo, idx = portfolio(tr)
        m = metrics(combo, idx, np.zeros_like(combo), bpy)
        cut = int(len(combo) * OOS_FRAC)
        mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
        py = per_year(combo, idx)
        neg_years = sum(1 for y, v in py.items() if v < 0)
        print(f"    span {span:>3d}: full {m['sharpe']:>4.2f}  OOS {mo['sharpe']:>4.2f}  "
              f"DD {m['max_dd']*100:>4.1f}%  CAGR {m['cagr']*100:>5.1f}%  neg-years {neg_years}")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--quick", action="store_true")
    args = ap.parse_args()

    print("#" * 120)
    print("# TRACK H — VOLUME / RANGE / VOLATILITY-REGIME on certified BTC/ETH (Deribit mainnet)")
    print("# Honest engine: target decided <=close[i], held bar i+1; fee on |Δpos|; OOS 65/35; >=12h only.")
    print("#" * 120)

    tfs = ["12h"] if args.quick else ["12h", "1d"]
    for tf in tfs:
        results, tp = report_headline(tf, args.quick)
        report_peryear(results)
        if tf == "12h":
            crosscheck_backtest_signals()
    report_grid(args.quick)
    report_feesweep()
    report_regime_filter("12h")

    print("\n" + "#" * 120)
    print("# VERDICT (track H) — honest reading of the tables above")
    print("#" * 120)
    for line in [
        "1. NO uncorrelated additive edge found. Every PROFITABLE volume/range/vol signal",
        "   (VolBreakout, OBV-trend, VW-mom, VT-long) is trend-in-disguise: corr-to-TP01 0.61-0.75.",
        "   They do not diversify TP01 -> cannot raise the 50/50 portfolio Sharpe.",
        "2. The genuinely LOWER-corr signals (RangeExpand ~0.48, NR7 ~0.48) FAIL OOS on >=1 asset",
        "   (NR7 ETH OOS Sharpe ~0.0/-0.03; RangeExpand BTC weak, ETH negative on 1d). Not deployable.",
        "3. Declining-volume / fade (mean-reversion) is firmly NEGATIVE net of fees on both assets",
        "   and at ZERO fee -> confirms the v2.0.0 lesson: MR edge was feed contamination, it is dead.",
        "4. Vol-REGIME gating of TP01 (keep low-vol / keep high-vol) HURTS Sharpe (1.32 -> 0.94/0.98).",
        "   A vol-managed x1.5 overlay leaves Sharpe ~flat (1.33) but raises DD (17.9%). No win.",
        "5. The ONLY non-harmful overlay is an OBV-up trend-CONFIRMATION filter (keep TP01 long only",
        "   while OBV>EMA): full Sharpe 1.32->1.49, maxDD 13.3%->10.1%, but CAGR 16.2%->14.4%, turnover",
        "   +60%, and OOS gain is marginal (0.90->1.04) and span-sensitive (fades for EMA>45). It is",
        "   trend double-confirmation (de-risking), NOT new alpha. Worth noting as a DEFENSIVE overlay",
        "   if cutting DD matters more than CAGR; it does NOT robustly raise the portfolio Sharpe.",
        "BOTTOM LINE: the ~1.3 portfolio-Sharpe ceiling on BTC/ETH-only HOLDS. Volume/range/vol add",
        "nothing uncorrelated. TP01 stays the deployable winner.",
    ]:
        print("  " + line)
    print("#" * 120)


def crosscheck_backtest_signals():
    """Cross-check two DISCRETE signals through the canonical harness `backtest_signals`
    (decide<=close[i], fill at close[i]) to confirm the per-bar engine isn't flattering them."""
    print("\n" + "-" * 120)
    print("# CROSS-CHECK via harness.backtest_signals (discrete entries, fee 0.10% RT, TF 12h)")
    print("-" * 120)
    tf = "12h"
    for a in ASSETS:
        df = resample_tf(load(a, "1h"), tf)
        h = df["high"].values.astype(float)
        l = df["low"].values.astype(float)
        c = df["close"].values.astype(float)
        rng = h - l
        avg = roll_mean_prior(rng, 20)
        pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
        expand = rng > 1.5 * avg
        entries = [None] * len(df)
        for i in range(len(df)):
            if expand[i] and pos_in_bar[i] > 0.6:
                entries[i] = dict(dir=1, tp=None, sl=None, max_bars=5)
        m = backtest_signals(df, entries, fee_rt=0.001, leverage=1.0, asset=a, tf=tf)
        m.print_summary(f"RangeExpand(L,5b) {a}")


if __name__ == "__main__":
    main()
