"""READ-ONLY evaluator for the Bybit-vs-Deribit CROSS-VENUE FUNDING-CARRY edge (delta-neutral level harvest).

Thesis: Bybit perp funding runs PERSISTENTLY higher than Deribit on the liquid majors. A delta-neutral
SHORT-Bybit / LONG-Deribit position on the SAME coin harvests the funding SPREAD (bybit − deribit) with
~cancelled price risk. STATIC = always short-Bybit/long-Deribit (harvest the persistent positive level, low
turnover); DYNAMIC = position by the sign of the spread (per-flip). Equal-weight across the overlap coins.

NO network — in-memory SQLite stores seeded directly. Tests assert: the per-coin/day causal spread; static
vs dynamic returns; cost reduces (dynamic more than static); a persistent-positive-spread fixture is
harvested by static (PASS) while a zero/arbed spread is ~flat (FAIL); verify cost-monotone + per-year +
corr-vs-4-edges; walk-forward OOS gate; look-ahead clean; READ-ONLY (WriteTripwire never trips).
"""
from __future__ import annotations

import math

from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.cross_venue_carry_eval import (
    _gate_included,
    carry_spread,
    cross_venue_carry_metrics_from_store,
    cross_venue_carry_returns,
    gated_book_from_spread,
    gated_look_ahead_audit,
    persistence_score,
    verify_cross_venue_carry,
    walk_forward_cross_venue_carry,
    walk_forward_cross_venue_carry_gated,
)

_DAY = 86_400


class _WriteTripwireStore:
    """Read-only proxy: forwards reads, but ANY write/persist call trips an assertion (the READ-ONLY guarantee)."""

    _FORBIDDEN = frozenset({
        "write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
        "replace_positions", "upsert_nav", "upsert_sleeve_ret", "replace_shadow_positions",
        "replace_trades", "_create_schema", "_bulk_upsert",
    })

    def __init__(self, inner: TimescaleFeatureStore) -> None:
        object.__setattr__(self, "_inner", inner)

    def __getattr__(self, name: str):
        if name in _WriteTripwireStore._FORBIDDEN:
            raise AssertionError(f"READ-ONLY violation: evaluator called write method {name!r}")
        return getattr(self._inner, name)


# --- 1. carry_spread ----------------------------------------------------------------------------

def test_carry_spread_is_bybit_minus_deribit_per_coin_day():
    bybit = {"BTCUSDT": {0: 0.0004, 1: 0.0005}, "ETHUSDT": {0: 0.0003}}
    deribit = {"BTCUSDT": {0: 0.000276, 1: 0.000200}, "ETHUSDT": {0: 0.000150}}
    sp = carry_spread(bybit, deribit)
    assert math.isclose(sp[0]["BTCUSDT"], 0.0004 - 0.000276, abs_tol=1e-15)
    assert math.isclose(sp[1]["BTCUSDT"], 0.0005 - 0.000200, abs_tol=1e-15)
    assert math.isclose(sp[0]["ETHUSDT"], 0.0003 - 0.000150, abs_tol=1e-15)


def test_carry_spread_excludes_non_overlap_and_one_venue_days():
    bybit = {"BTCUSDT": {0: 0.001, 1: 0.001}, "ONLYBYBITUSDT": {0: 0.002}}
    deribit = {"BTCUSDT": {0: 0.0005}, "ONLYDERIBITUSDT": {0: 0.0009}}
    sp = carry_spread(bybit, deribit)
    assert 0 in sp and "BTCUSDT" in sp[0]
    assert 1 not in sp                       # BTC day-1 absent on Deribit
    for day in sp:
        assert "ONLYBYBITUSDT" not in sp[day]
        assert "ONLYDERIBITUSDT" not in sp[day]


# --- 2. fixtures: a persistent positive spread vs an arbed (zero) spread -------------------------

def _seed_persistent_positive(bybit: TimescaleFeatureStore, deribit: TimescaleFeatureStore, *,
                              days: int = 120, start_day: int = 0) -> None:
    """Bybit funding PERSISTENTLY ~+4bp/day above Deribit on BTC + ETH (the live thesis). Static
    short-Bybit/long-Deribit harvests the level. close/spot are flat (delta-neutral; price risk cancels)."""
    for i in range(days):
        d = start_day + i
        # Spread is PERSISTENTLY positive (~+12.4bp) with tiny day-to-day noise so the Sharpe is well-defined
        # (a perfectly constant spread has zero variance → Sharpe undefined/0, but is still harvested).
        jitter = 0.00002 * (1 if i % 2 == 0 else -1)
        for sym in ("BTCUSDT", "ETHUSDT"):
            bybit.write_features(sym, [(d * _DAY, {"funding": 0.0004 + jitter, "close": 100.0,
                                                   "spot_close": 100.0})])
            deribit.write_features(sym, [(d * _DAY, {"deribit_funding": 0.000276})])


def _seed_arbed_zero(bybit: TimescaleFeatureStore, deribit: TimescaleFeatureStore, *,
                     days: int = 120) -> None:
    """Spread ≈ 0 (the venues are arbed): identical funding on both. Static should be ~flat → FAIL."""
    for d in range(days):
        for sym in ("BTCUSDT", "ETHUSDT"):
            bybit.write_features(sym, [(d * _DAY, {"funding": 0.0003, "close": 100.0, "spot_close": 100.0})])
            deribit.write_features(sym, [(d * _DAY, {"deribit_funding": 0.0003})])


def test_static_harvests_persistent_positive_spread():
    bybit = TimescaleFeatureStore("sqlite://", table="bybit_features")
    deribit = TimescaleFeatureStore("sqlite://", table="bybit_features")   # same namespace; merged below
    _seed_persistent_positive(bybit, deribit)
    # Single store holds both features (deribit_funding written alongside Bybit funding).
    _merge_deribit_into(bybit, deribit)
    m = cross_venue_carry_metrics_from_store(bybit, cost_bps=0.0, mode="static")
    bybit.close()
    deribit.close()
    assert m["available"] is True
    assert m["n_coins"] == 2
    assert m["total_return"] > 0.0          # the persistent positive spread is harvested
    assert m["sharpe"] > 0.0


def test_arbed_zero_spread_is_flat_and_fails():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_arbed_zero(store, store)
    m = cross_venue_carry_metrics_from_store(store, cost_bps=0.0, mode="static")
    store.close()
    # An arbed (zero-spread) market yields ~no carry — total return ~0.
    assert abs(m["total_return"]) < 1e-6


def _merge_deribit_into(target: TimescaleFeatureStore, source: TimescaleFeatureStore) -> None:
    """Copy `deribit_funding` rows from `source` into `target` (the live ingest writes both features into the
    SAME bybit_features table; the fixtures seed two handles, so merge for the single-store reads)."""
    panel = source.read_panel(["BTCUSDT", "ETHUSDT", "SOLUSDT"], "deribit_funding")
    for sym, series in panel.items():
        if series:
            target.write_features(sym, [(day * _DAY, {"deribit_funding": v}) for day, v in series.items()])


# --- 3. static vs dynamic returns + cost ---------------------------------------------------------

def _seed_oscillating(store: TimescaleFeatureStore, *, days: int = 200, start_day: int = 18628) -> None:
    """Spread that OSCILLATES around a positive mean: positive most days, occasionally negative. Dynamic
    (sign-following) flips on the sign changes (turnover/cost); static holds (low turnover). Multi-year span."""
    for i in range(days):
        d = start_day + i
        # mean +3bp, oscillation ±5bp → flips sign ~half the time (dynamic turns over a lot).
        osc = 0.0005 * (1 if i % 2 == 0 else -1)
        for sym in ("BTCUSDT", "ETHUSDT"):
            bybit_f = 0.0003 + (0.00015 if sym == "BTCUSDT" else 0.0)
            deribit_f = bybit_f - (0.0003 + osc)   # spread = 0.0003 + osc (oscillates around +3bp)
            store.write_features(sym, [(d * _DAY, {"funding": bybit_f, "close": 100.0, "spot_close": 100.0,
                                                   "deribit_funding": deribit_f})])


def test_static_and_dynamic_differ_and_cost_reduces():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_oscillating(store)
    s_gross = cross_venue_carry_returns(store, cost_bps=0.0, mode="static")
    d_gross = cross_venue_carry_returns(store, cost_bps=0.0, mode="dynamic")
    s_net = cross_venue_carry_returns(store, cost_bps=11.0, mode="static")
    d_net = cross_venue_carry_returns(store, cost_bps=11.0, mode="dynamic")
    store.close()

    sum_ = lambda ser: sum(ser.values())  # noqa: E731
    # Cost reduces both.
    assert sum_(s_net) <= sum_(s_gross)
    assert sum_(d_net) <= sum_(d_gross)
    # Dynamic flips on sign changes (high turnover) → cost bites it MORE than static (which barely turns over).
    static_cost_drag = sum_(s_gross) - sum_(s_net)
    dynamic_cost_drag = sum_(d_gross) - sum_(d_net)
    assert dynamic_cost_drag > static_cost_drag


def test_dynamic_position_follows_spread_sign():
    # Dynamic earns |spread| (always non-negative gross before cost); static earns the signed spread.
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    # One coin, spread negative on every day → static LOSES (gross<0), dynamic WINS (|spread|>0).
    for d in range(60):
        store.write_features("BTCUSDT", [(d * _DAY, {"funding": 0.0001, "close": 100.0, "spot_close": 100.0,
                                                     "deribit_funding": 0.0004})])
        store.write_features("ETHUSDT", [(d * _DAY, {"funding": 0.0001, "close": 100.0, "spot_close": 100.0,
                                                     "deribit_funding": 0.0004})])
    s = cross_venue_carry_returns(store, cost_bps=0.0, mode="static")
    dyn = cross_venue_carry_returns(store, cost_bps=0.0, mode="dynamic")
    store.close()
    assert sum(s.values()) < 0.0     # persistent NEGATIVE spread loses for static short-Bybit
    assert sum(dyn.values()) > 0.0   # dynamic flips to long-Bybit and harvests |spread|


# --- 4. read-only ------------------------------------------------------------------------------

def test_metrics_is_read_only():
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_persistent_positive(inner, inner)
    guarded = _WriteTripwireStore(inner)
    m = cross_venue_carry_metrics_from_store(guarded, cost_bps=11.0, mode="static")
    inner.close()
    assert m["available"] is True


def test_metrics_na_when_no_overlap():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    store.write_features("BTCUSDT", [(0, {"funding": 0.0004})])   # no deribit_funding anywhere
    m = cross_venue_carry_metrics_from_store(store, mode="static")
    store.close()
    assert m["available"] is False
    assert "reason" in m and m["reason"]


# --- 5. verify ----------------------------------------------------------------------------------

def test_verify_cost_monotone_and_per_year_and_corr():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_oscillating(store, days=400)   # spans 2021..2022
    rep = verify_cross_venue_carry(store, cost_bps=11.0, cost_grid=(11.0, 22.0, 44.0))
    store.close()

    assert rep["available"] is True
    assert rep["n_coins"] == 2
    for mode in ("static", "dynamic"):
        nc = rep["net_cost"][mode]
        assert nc[11.0]["sharpe"] >= nc[22.0]["sharpe"] >= nc[44.0]["sharpe"]   # cost-monotone
    # Per-year on the right slices.
    py = rep["per_year"]["static"]
    assert "2021" in py and "2022" in py
    # corr-vs-4-edges present (each is a float in [-1,1] or None).
    corr = rep["corr_edges"]["static"]
    for edge in ("tstrend", "unlock", "xsfunding", "positioning"):
        assert edge in corr
        c = corr[edge]
        assert c is None or (-1.0 - 1e-9 <= c <= 1.0 + 1e-9)


# --- 6. walk-forward OOS gate -------------------------------------------------------------------

def test_walk_forward_runs_and_picks_oos_mode():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_oscillating(store, days=400)
    rep = walk_forward_cross_venue_carry(store, cost_bps=11.0, window_days=120)
    store.close()

    assert rep["available"] is True
    assert rep["train_holdout"]["train_mode"] in ("static", "dynamic")
    assert "test_sharpe" in rep["train_holdout"]
    assert "rolling_windows" in rep
    assert rep["look_ahead"]["causal"] is True
    assert "verdict" in rep and "deployable" in rep["verdict"]


def test_walk_forward_static_passes_on_persistent_positive():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_persistent_positive(store, store, days=400, start_day=18628)
    rep = walk_forward_cross_venue_carry(store, cost_bps=0.0, window_days=120)
    store.close()
    # A persistent positive spread should pick static OOS and be positive in (most) windows.
    assert rep["train_holdout"]["test_sharpe"] >= 0.0
    assert rep["rolling_windows"]["fraction_positive"] >= 0.5


def test_look_ahead_audit_bites_on_leaked_candidate():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_oscillating(store, days=80)
    clean = walk_forward_cross_venue_carry(store, cost_bps=0.0)["look_ahead"]
    store.close()
    assert clean["causal"] is True
    assert clean["leak_days"] == 0


# --- 7. CLI smoke (mocked store, no network) ----------------------------------------------------

def _cli_store_patch(monkeypatch, store):
    import fxhnt.cli as cli  # noqa: F401
    monkeypatch.setattr(
        "fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
        lambda *_a, **_k: store, raising=False)


def test_cli_cross_venue_carry_eval_prints_metrics(monkeypatch):
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_persistent_positive(store, store)
    _cli_store_patch(monkeypatch, store)

    result = CliRunner().invoke(cli.app, ["cross-venue-carry-eval", "--all", "--mode", "static",
                                          "--cost-bps", "11"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "deribit" in out
    assert "spread" in out
    assert "n-overlap-coins" in out


def test_cli_cross_venue_carry_eval_verify(monkeypatch):
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_oscillating(store, days=400)
    _cli_store_patch(monkeypatch, store)

    result = CliRunner().invoke(cli.app, ["cross-venue-carry-eval", "--all", "--verify"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "static" in out
    assert "dynamic" in out
    assert "xsfunding" in out


def test_cli_cross_venue_carry_eval_walk_forward(monkeypatch):
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_oscillating(store, days=400)
    _cli_store_patch(monkeypatch, store)

    result = CliRunner().invoke(cli.app, ["cross-venue-carry-eval", "--all", "--walk-forward"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "verdict" in out
    assert "look-ahead" in out


def test_cli_ingest_deribit_funding(monkeypatch):
    """CLI wiring smoke for `ingest-deribit-funding`: a fake adapter + a captured store (a thin subclass so
    the class's internal `TimescaleFeatureStore._flush_batch` self-reference is NOT shadowed) stand in for the
    network + warehouse. Asserts the command writes 'deribit_funding' for the mapped symbol and reports it."""
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")

    class _FakeDer:
        def funding_history(self, instrument, *, start_ms=None):
            return {100: 0.0002, 101: 0.0003} if instrument == "BTC-PERPETUAL" else {}

    # Subclass the REAL store class so the in-class `TimescaleFeatureStore._flush_batch` self-reference still
    # resolves (we do NOT shadow the module-level class name with a function). The subclass's constructor
    # ignores the operational DSN and adopts our captured in-memory store's engine + table.
    class _CapturedStore(TimescaleFeatureStore):
        def __init__(self, *_a, **_k):  # noqa: ANN002, ANN003 — test shim
            object.__setattr__(self, "_engine", store._engine)
            object.__setattr__(self, "_table", store._table)

        def close(self) -> None:        # keep the shared engine alive for the post-command read
            pass

    monkeypatch.setattr(
        "fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
        _CapturedStore, raising=False)
    monkeypatch.setattr("fxhnt.adapters.data.deribit_funding.DeribitFunding",
                        lambda *_a, **_k: _FakeDer(), raising=False)

    result = CliRunner().invoke(cli.app, ["ingest-deribit-funding", "--instruments", "BTC-PERPETUAL"])
    assert result.exit_code == 0, result.output
    assert "deribit-funding done" in result.output.lower()
    assert "1 instruments" in result.output
    assert store.read_panel(["BTCUSDT"], "deribit_funding")["BTCUSDT"] == {100: 0.0002, 101: 0.0003}


# --- 8. GATED-STATIC: causal persistence score + gate ------------------------------------------

def test_persistence_score_is_trailing_mean_strictly_before_t():
    # Coin spread: days 0..4 = [1, 2, 3, 4, 5] (in arbitrary spread units).
    cs = {0: 1.0, 1: 2.0, 2: 3.0, 3: 4.0, 4: 5.0}
    # At t=4, lookback=2 → window [2, 3] (STRICTLY before 4) → mean(3, 4) = 3.5. Day-4 value (5) NOT used.
    assert math.isclose(persistence_score(cs, 4, lookback=2), 3.5, abs_tol=1e-12)
    # At t=4, lookback=4 → window [0, 3] → mean(1, 2, 3, 4) = 2.5.
    assert math.isclose(persistence_score(cs, 4, lookback=4), 2.5, abs_tol=1e-12)


def test_persistence_score_none_when_no_prior_window():
    cs = {5: 1.0, 6: 2.0}
    # At t=5 there is NO day < 5 in window → None (cannot be scored → must be excluded, never a default-in).
    assert persistence_score(cs, 5, lookback=30) is None


def test_gate_look_ahead_a_spread_positive_only_at_t_is_not_included_at_t():
    # The look-ahead fixture from the spec: a coin whose spread turns positive only AT day t must NOT be
    # included at t (the gate uses ONLY days < t). Prior window is all-negative → causal score < 0.
    cs = {0: -0.001, 1: -0.001, 2: -0.001, 3: +0.010}   # only day 3 is positive
    # threshold 0: causal score at t=3 = mean(days 0..2) = -0.001 < 0 → EXCLUDED (no peek at day-3's +0.01).
    assert _gate_included(cs, 3, lookback=3, threshold=0.0) is False
    # Sanity: by day 4 the +0.01 has entered the trailing window and can pull the mean up.
    assert persistence_score(cs, 4, lookback=3) is not None


def test_gate_includes_persistent_positive_excludes_flipper_and_negative():
    persistent = {d: +0.0004 for d in range(60)}        # always positive premium
    flipper = {d: (0.0004 if d % 2 == 0 else -0.0004) for d in range(60)}   # flips → trailing mean ~0
    negative = {d: -0.0004 for d in range(60)}          # always negative
    thr = 1e-4
    assert _gate_included(persistent, 59, lookback=30, threshold=thr) is True
    assert _gate_included(flipper, 59, lookback=30, threshold=thr) is False  # mean ~0 < threshold
    assert _gate_included(negative, 59, lookback=30, threshold=thr) is False


# --- 9. GATED-STATIC: equal-weight basket over the included set --------------------------------

def test_gated_basket_equal_weights_included_and_flat_when_none():
    # 2 coins persistently positive, 1 coin always negative. With threshold>0 the gate includes the 2 positives
    # (equal-weight) and excludes the negative; gross/day == mean of the 2 positive spreads.
    spread = {}
    for d in range(40):
        spread[d] = {"BTCUSDT": 0.0004, "ETHUSDT": 0.0006, "BADUSDT": -0.0005}
    bk = gated_book_from_spread(spread, lookback=10, threshold=1e-4, cost_bps=0.0)
    # Once the trailing window has data (d>=10ish), included = {BTC, ETH}, gross = mean(0.0004, 0.0006)=0.0005.
    later = [d for d in bk["gross"] if d >= 20]
    assert later, "expected post-warmup days"
    for d in later:
        assert math.isclose(bk["gross"][d], 0.0005, abs_tol=1e-9)
    assert "BADUSDT" not in bk["inclusion"]              # the negative coin is never selected
    assert bk["inclusion"]["BTCUSDT"] > 0 and bk["inclusion"]["ETHUSDT"] > 0


def test_gated_flat_when_none_included():
    # Threshold above EVERY coin's premium → nothing is ever included → flat (zero) returns, no per_coin.
    spread = {d: {"BTCUSDT": 0.0001, "ETHUSDT": 0.0001} for d in range(40)}
    bk = gated_book_from_spread(spread, lookback=10, threshold=0.01, cost_bps=0.0)
    assert bk["included_days"] == 0
    assert all(v == 0.0 for v in bk["gross"].values())
    assert bk["per_coin"] == {}


# --- 10. GATED isolates the persistent subset, beating plain-static-over-all -------------------

def test_gated_isolates_persistent_subset_and_beats_plain_static():
    # 5 coins: 2 with a PERSISTENT positive premium, 3 FLIPPERS (spread averages ~0, no persistent premium).
    # Gated should isolate the 2 persistent coins → Sharpe >> plain-static-over-all-5 (which dilutes).
    import statistics as _st

    def _sharpe(series):
        rets = [series[d] for d in sorted(series)]
        sd = _st.pstdev(rets)
        return (_st.mean(rets) / sd * math.sqrt(365)) if sd > 0 and len(rets) >= 2 else 0.0

    persistent = ("AAAUSDT", "BBBUSDT")
    flippers = ("CCCUSDT", "DDDUSDT", "EEEUSDT")
    spread = {}
    for d in range(200):
        row = {}
        jitter = 0.00002 * (1 if d % 2 == 0 else -1)
        for c in persistent:
            row[c] = 0.0004 + jitter
        for i, c in enumerate(flippers):
            # large-amplitude sign flips → trailing mean stays ~0 (no persistent premium)
            row[c] = 0.0010 * (1 if (d + i) % 2 == 0 else -1)
        spread[d] = row

    bk = gated_book_from_spread(spread, lookback=30, threshold=1e-4, cost_bps=0.0)
    gated = bk["net"]
    plain = {}   # plain-static = always include ALL 5 coins (equal weight)
    for d in sorted(spread):
        row = spread[d]
        plain[d] = sum(row.values()) / len(row)

    # Gated keeps the 2 persistent coins on (nearly) EVERY scored day; the flippers' trailing mean only
    # intermittently/never clears the threshold, so they are included FAR less (the gate isolates the subset).
    incl = bk["inclusion"]
    n_scored = bk["included_days"]
    for c in persistent:
        assert incl.get(c, 0) >= 0.9 * n_scored          # persistent coins almost always selected
    for c in flippers:
        assert incl.get(c, 0) <= 0.6 * n_scored          # flippers selected much less (mean ~0)
        assert incl.get(c, 0) < incl[persistent[0]]
    # And its Sharpe is much higher than plain-static-over-all-5 (which dilutes with the flippers).
    assert _sharpe(gated) > 3.0 * _sharpe(plain)


# --- 11. GATED walk-forward: pick (L,threshold) on train, score OOS; small grid; read-only -----

def _seed_two_persistent_three_flippers(store: TimescaleFeatureStore, *, days: int = 400,
                                        start_day: int = 18628) -> None:
    """Bybit/Deribit funding for 2 PERSISTENT-premium majors (BTC/ETH) + 3 FLIPPER alts in ONE store."""
    persistent = ("BTCUSDT", "ETHUSDT")
    flippers = ("SOLUSDT", "ADAUSDT", "XLMUSDT")
    for i in range(days):
        d = start_day + i
        jitter = 0.00002 * (1 if i % 2 == 0 else -1)
        for c in persistent:
            store.write_features(c, [(d * _DAY, {"funding": 0.0004 + jitter, "close": 100.0,
                                                 "spot_close": 100.0, "deribit_funding": 0.000276})])
        for j, c in enumerate(flippers):
            flip = 0.0010 * (1 if (i + j) % 2 == 0 else -1)
            store.write_features(c, [(d * _DAY, {"funding": 0.0003 + flip, "close": 100.0,
                                                 "spot_close": 100.0, "deribit_funding": 0.0003})])


def test_gated_walk_forward_picks_on_train_scores_oos_and_isolates_majors():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_two_persistent_three_flippers(store)
    rep = walk_forward_cross_venue_carry_gated(store, cost_bps=0.0, window_days=120)
    store.close()

    assert rep["available"] is True
    # (L, threshold) chosen from the SMALL documented grid only.
    assert rep["chosen"]["lookback"] in (30, 60, 90)
    assert rep["chosen"]["threshold"] in (0.0, 5e-5, 1e-4, 2e-4)
    assert rep["grid"]["L"] == [30, 60, 90]
    # OOS number present; gate rediscovers BTC/ETH (the retail majors) and selects them FAR more than the
    # flippers (inclusion fraction over the TEST window).
    assert "oos_sharpe" in rep
    sel = rep["selected_coins"]   # {coin: inclusion_fraction over TEST}
    assert sel.get("BTCUSDT", 0.0) >= 0.9 and sel.get("ETHUSDT", 0.0) >= 0.9
    for flip in ("SOLUSDT", "ADAUSDT", "XLMUSDT"):
        assert sel.get(flip, 0.0) < 0.6                  # flippers selected much less than the majors
        assert sel.get(flip, 0.0) < sel["BTCUSDT"]
    assert set(rep["selected_retail_majors"]) >= {"BTCUSDT", "ETHUSDT"}
    # The HONEST comparison table is present.
    cmp = rep["comparison"]
    assert "gated_oos_sharpe" in cmp and "plain_static_oos_sharpe" in cmp
    assert "btceth_static_oos_sharpe" in cmp
    # Gated OOS should beat plain-static OOS when there ARE flippers to drop.
    assert cmp["gated_oos_sharpe"] > cmp["plain_static_oos_sharpe"]
    # Look-ahead clean + verdict present.
    assert rep["look_ahead"]["causal"] is True
    assert "interpretation" in rep["verdict"]


def test_gated_walk_forward_cost_monotone():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_two_persistent_three_flippers(store)
    cheap = walk_forward_cross_venue_carry_gated(store, cost_bps=0.0, window_days=120)["full_sharpe"]
    dear = walk_forward_cross_venue_carry_gated(store, cost_bps=44.0, window_days=120)["full_sharpe"]
    store.close()
    # More cost on the gate's entry/exit turnover never increases the realized Sharpe.
    assert dear <= cheap + 1e-9


def test_gated_walk_forward_look_ahead_audit_is_non_vacuous_and_clean():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_two_persistent_three_flippers(store)
    rep = walk_forward_cross_venue_carry_gated(store, cost_bps=0.0)
    store.close()
    la = rep["look_ahead"]
    assert la["causal"] is True
    assert la["leak_days"] == 0
    # The leaked (peeking) gate WOULD change at least one inclusion decision → the audit is meaningful, not
    # vacuously clean (the flippers' sign flips make day-t inclusion differ from day-(<t) inclusion).
    assert la["leaked_differs_on_days"] >= 1


def test_gated_walk_forward_is_read_only():
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_two_persistent_three_flippers(inner, days=120)
    guarded = _WriteTripwireStore(inner)
    rep = walk_forward_cross_venue_carry_gated(guarded, cost_bps=11.0)
    inner.close()
    assert rep["available"] is True


def test_gated_walk_forward_na_when_no_overlap():
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    store.write_features("BTCUSDT", [(0, {"funding": 0.0004})])   # no deribit_funding anywhere
    rep = walk_forward_cross_venue_carry_gated(store)
    store.close()
    assert rep["available"] is False
    assert "reason" in rep


def test_gated_decay_to_plain_static_is_honest_btceth_only_verdict():
    # When there are NO flippers to drop (all coins persistent), gated == plain-static → the verdict must be
    # the HONEST "doesn't broaden" interpretation (a valid result), NOT a false "broader edge" claim.
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_persistent_positive(store, store, days=400, start_day=18628)   # BTC+ETH only, both persistent
    rep = walk_forward_cross_venue_carry_gated(store, cost_bps=0.0, window_days=120)
    store.close()
    # gated OOS ~ plain-static OOS (same coins) → not "broader".
    assert rep["verdict"]["checks"]["oos_beats_plain_static"] is False
    assert ("BTC/ETH-only" in rep["verdict"]["interpretation"]
            or "DECAYS" in rep["verdict"]["interpretation"]
            or "no harvestable" in rep["verdict"]["interpretation"])


# --- 12. GATED CLI smoke ----------------------------------------------------------------------

def test_cli_cross_venue_carry_eval_gated_walk_forward(monkeypatch):
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_two_persistent_three_flippers(store)
    _cli_store_patch(monkeypatch, store)

    result = CliRunner().invoke(cli.app, ["cross-venue-carry-eval", "--all", "--mode", "gated",
                                          "--walk-forward"])
    store.close()
    assert result.exit_code == 0, result.output
    out = result.output.lower()
    assert "gated" in out
    assert "selected coins" in out
    assert "honest oos comparison" in out
    assert "plain-static" in out
    assert "interpretation" in out


def test_gated_look_ahead_audit_direct():
    # Direct unit test of the gated look-ahead audit on a hand-built spread panel with flippers.
    spread = {}
    for d in range(40):
        spread[d] = {"BTCUSDT": 0.0004, "FLIPUSDT": 0.001 * (1 if d % 2 == 0 else -1)}
    audit = gated_look_ahead_audit(spread, lookback=10, threshold=1e-4)
    assert audit["causal"] is True
    assert audit["leak_days"] == 0
    assert audit["leaked_differs_on_days"] >= 1
