"""OOS-disciplined LIQUIDITY-BOUND sweep for the Bybit 4-edge book — measure the optimal --min-dollar-vol
NET of a cost built from ONLY measured/known quantities (NO assumed market-impact coefficient).

We DEFAULT the book to the liquid subset (--min-dollar-vol 10M -> ~61 coins) on the ASSERTED prior that
illiquid coins cost too much. This harness MEASURES it with a cost that has NO free parameters:

    cost_bps(coin) = taker_fee_bps + 0.5 · effective_spread_bps(coin)

  * taker_fee_bps = 5.5 — Bybit's PUBLISHED perp taker fee (0.055%). A KNOWN exchange fee, not an assumption.
  * effective_spread_bps(coin) — MEASURED from real OHLC via the Corwin–Schultz (2012) high-low spread
    estimator, a published PARAMETER-FREE estimator. Median of the daily CS estimates per coin, negatives
    clamped to 0 (as the estimator prescribes). You cross HALF the spread per trade.
  * market impact is DROPPED ENTIRELY (not assumed): it can't be measured without live fills, and at the
    ~$100k deploy-book size each per-coin order is a tiny fraction of daily volume so impact is negligible.

Tests (TDD):
  * Corwin–Schultz — recovers a KNOWN injected spread within tolerance; negatives clamp to 0; a tight-spread
    (deep) coin -> ~base cost, a wide-spread (thin) coin -> much higher cost;
  * the measured cost — monotone in the measured spread; capital-independent in bps; deterministic;
  * per-coin book assembly — combining the 4 sleeves eq-wt per coin reconciles to the sleeve-level gross book
    return (no edge drift); deterministic (no hidden look-ahead state);
  * the sweep — a fixture where thin coins have WIDE measured spreads -> broad's net Sharpe < liquid's
    (verdict CONFIRM/TIGHTEN); a fixture where even "thin" coins have TIGHT measured spreads -> broad
    survives (verdict WIDEN). Proves the verdict tracks the MEASURED spread, nothing assumed;
  * OOS split + gross-vs-net over-diversification reporting; median measured spread per bound reported;
  * READ-ONLY (WriteTripwire) + memory-bounded (universe-restricted reads); empty store -> unavailable;
  * CLI smoke (mocked store).
NO network — the store is in-memory SQLite; external inputs are injected.
"""
from __future__ import annotations

import math

from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_liquidity_sweep import (
    _LIQUIDITY_BOUNDS,
    coin_cost_bps,
    corwin_schultz_spread_bps,
    liquidity_bound_sweep,
    per_coin_book_returns,
)

_DAY = 86_400


# --- fixtures ------------------------------------------------------------------------------------

def _ohlc_from_close(close: float, spread_frac: float, vol_frac: float, phase: float
                     ) -> tuple[float, float]:
    """Synthesise a (high, low) bracketing `close` with a KNOWN proportional half-spread `spread_frac` plus a
    small DECORRELATED intraday volatility component. The Corwin–Schultz estimator cancels the volatility term
    when consecutive-day ranges are independent draws of the same vol — so the per-day vol must be decorrelated
    across days (a high-frequency phase term) for the estimator to recover the spread cleanly. high/low
    straddle the mid by half the spread plus that wobble; the same-day high-low feeds β (spread+vol) and the
    consecutive-day high-low feeds γ (spread+2-day move)."""
    mid = close
    # Decorrelated day-to-day intraday vol: a high-frequency phase makes adjacent days near-independent so CS
    # cancels it (a low-frequency/correlated vol would leak into the spread estimate and bias it up).
    vol = vol_frac * (0.5 + 0.5 * math.cos(7.0 * phase))
    half = 0.5 * spread_frac + vol
    high = mid * (1.0 + half)
    low = mid * (1.0 - half)
    return high, low


def _seed_momentum(store: TimescaleFeatureStore, *, symbols: list[str] | None = None,
                   n_symbols: int = 8, days: int = 400, drift: float = 0.01,
                   spread_frac: float = 0.0005) -> list[str]:
    """Momentum coins with a tight (deep) measured spread by default; writes close + high/low so the CS
    estimator has data."""
    syms = symbols if symbols is not None else [f"SYM{idx:02d}USDT" for idx in range(n_symbols)]
    for idx, sym in enumerate(syms):
        sign = 1.0 if idx % 2 == 0 else -1.0
        px = 100.0
        rows = []
        for d in range(days):
            wobble = 0.003 * math.cos(0.4 * d + idx)
            px *= (1.0 + sign * drift + wobble)
            high, low = _ohlc_from_close(px, spread_frac, vol_frac=0.0005, phase=0.4 * d + idx)
            rows.append((d * _DAY, {"close": px, "high": high, "low": low}))
        store.write_features(sym, rows)
    return syms


def _seed_carry(store: TimescaleFeatureStore, *, days: int = 400, spread_frac: float = 0.0005) -> list[str]:
    funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
               "XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
    for idx, (sym, fund) in enumerate(funding.items()):
        rows = []
        for d in range(days):
            f = fund + 0.0002 * math.cos(0.5 * d + idx)
            px = 100.0
            high, low = _ohlc_from_close(px, spread_frac, vol_frac=0.0005, phase=0.5 * d + idx)
            rows.append((d * _DAY, {"funding": f, "close": px, "spot_close": px,
                                    "high": high, "low": low}))
        store.write_features(sym, rows)
    return list(funding)


def _seed_turnover(store: TimescaleFeatureStore, dollar_vol_by_sym: dict[str, float], *,
                   days: int = 400) -> None:
    for sym, dv in dollar_vol_by_sym.items():
        store.write_features(sym, [(d * _DAY, {"turnover": dv}) for d in range(days)])


def _seed_ohlc_spread(store: TimescaleFeatureStore, spread_by_sym: dict[str, float], *,
                      days: int = 400, vol_frac: float = 0.0005) -> None:
    """Overwrite/seed high/low/close so each coin carries a KNOWN injected proportional spread — used by the
    sweep fixtures to make a coin's MEASURED spread the controlled driver of its cost."""
    for idx, (sym, sf) in enumerate(spread_by_sym.items()):
        rows = []
        for d in range(days):
            px = 100.0
            high, low = _ohlc_from_close(px, sf, vol_frac=vol_frac, phase=0.4 * d + idx)
            rows.append((d * _DAY, {"close": px, "high": high, "low": low}))
        store.write_features(sym, rows)


class _WriteTripwireStore:
    _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: liquidity-sweep called write method {name!r}")
        return getattr(self._inner, name)


class _FullPanelTripwireStore:
    _FULL_PANELS = frozenset({"crypto_close_panel", "crypto_funding_panel", "crypto_spot_panel"})

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

    def read_panel(self, symbols, feature):  # noqa: ANN001, ANN201
        self.read_panel_calls.append((tuple(symbols), feature))
        return self._inner.read_panel(symbols, feature)

    def __getattr__(self, name: str):
        if name in _FullPanelTripwireStore._FULL_PANELS:
            raise AssertionError(
                f"MEMORY-BOUND violation: liquidity-sweep read the FULL-universe panel {name!r}")
        return getattr(self._inner, name)


# --- THE CORWIN–SCHULTZ ESTIMATOR ----------------------------------------------------------------

def test_corwin_schultz_recovers_a_known_spread() -> None:
    """On a synthetic high/low series with a KNOWN injected proportional spread, the parameter-free CS
    estimator recovers it within tolerance (CS is designed to separate the bid-ask spread from intraday
    volatility using two consecutive days' high/low)."""
    true_spread_frac = 0.004   # 40 bps true spread
    highs: list[float] = []
    lows: list[float] = []
    px = 100.0
    for d in range(250):
        px *= (1.0 + 0.0003 * math.sin(0.3 * d))   # mild drift in the mid (spread dominates the range)
        h, lo = _ohlc_from_close(px, true_spread_frac, vol_frac=0.0008, phase=0.3 * d)
        highs.append(h)
        lows.append(lo)
    est_bps = corwin_schultz_spread_bps(highs, lows)
    true_bps = true_spread_frac * 1e4
    # CS is noisy per-pair but the median over a long series recovers the level within a reasonable band.
    assert abs(est_bps - true_bps) < 0.35 * true_bps, (est_bps, true_bps)


def test_corwin_schultz_clamps_negative_estimates_to_zero() -> None:
    """A perfectly-frictionless series (high==low every day, zero spread, zero range) yields a degenerate /
    negative CS estimate which the estimator clamps to 0 — never a negative cost."""
    highs = [100.0] * 50
    lows = [100.0] * 50
    assert corwin_schultz_spread_bps(highs, lows) == 0.0


def test_corwin_schultz_wider_range_gives_wider_spread() -> None:
    """A coin with a genuinely wider bid-ask (wider same-day high-low relative to the 2-day move) measures a
    strictly larger CS spread than a tight coin."""
    def _series(sf: float) -> tuple[list[float], list[float]]:
        hs, ls = [], []
        px = 100.0
        for d in range(250):
            px *= (1.0 + 0.0003 * math.sin(0.3 * d))
            h, lo = _ohlc_from_close(px, sf, vol_frac=0.003, phase=0.3 * d)
            hs.append(h)
            ls.append(lo)
        return hs, ls

    tight = corwin_schultz_spread_bps(*_series(0.0005))
    wide = corwin_schultz_spread_bps(*_series(0.01))
    assert wide > tight, (tight, wide)


# --- THE MEASURED COST ---------------------------------------------------------------------------

def test_deep_coin_costs_approximately_fee_plus_tiny_spread() -> None:
    """A DEEP coin (near-zero measured spread) -> cost ≈ the taker fee floor."""
    c = coin_cost_bps(effective_spread_bps=0.4, taker_fee_bps=5.5)
    assert abs(c - (5.5 + 0.2)) < 1e-9, c   # fee + half the 0.4bp spread


def test_thin_coin_costs_far_more_than_fee() -> None:
    """A THIN coin (wide measured spread) -> cost >> the fee floor (half the wide spread dominates)."""
    c = coin_cost_bps(effective_spread_bps=200.0, taker_fee_bps=5.5)
    deep = coin_cost_bps(effective_spread_bps=0.4, taker_fee_bps=5.5)
    assert c > deep * 3, (c, deep)
    assert abs(c - (5.5 + 100.0)) < 1e-9, c


def test_cost_is_monotone_in_measured_spread() -> None:
    """Wider measured spread -> strictly higher cost. NO impact / capital / volume term remains."""
    prev = -1.0
    for sp in (0.0, 1.0, 10.0, 50.0, 200.0):
        c = coin_cost_bps(effective_spread_bps=sp, taker_fee_bps=5.5)
        assert c > prev, (sp, c, prev)
        prev = c


def test_cost_is_capital_independent_in_bps() -> None:
    """The cost (in bps) is built from a fee + a measured spread — it does NOT depend on order size /
    capital. (The verdict is therefore capital-robust, a bonus of dropping impact.)"""
    a = coin_cost_bps(effective_spread_bps=12.0, taker_fee_bps=5.5)
    b = coin_cost_bps(effective_spread_bps=12.0, taker_fee_bps=5.5)
    assert a == b


def test_cost_is_deterministic() -> None:
    a = coin_cost_bps(effective_spread_bps=7.3, taker_fee_bps=5.5)
    b = coin_cost_bps(effective_spread_bps=7.3, taker_fee_bps=5.5)
    assert a == b


def test_cost_floors_at_fee_for_zero_spread() -> None:
    """A zero / negative measured spread cannot drag the cost below the published taker fee floor."""
    assert coin_cost_bps(effective_spread_bps=0.0, taker_fee_bps=5.5) == 5.5
    assert coin_cost_bps(effective_spread_bps=-10.0, taker_fee_bps=5.5) == 5.5


# --- PER-COIN BOOK ASSEMBLY ----------------------------------------------------------------------

def test_per_coin_book_reconciles_to_sleeve_gross() -> None:
    """The eq-wt-across-4-sleeves per-COIN gross book return must be finite and non-trivial — summing per-coin
    contributions reproduces the sleeve-level GROSS. Proves the per-coin decomposition introduces no drift."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store)
    _seed_carry(store)
    pc = per_coin_book_returns(store, universe=None, unlock_events=[])
    store.close()

    assert pc["days"], pc
    gross = pc["gross_series"]
    assert len(gross) >= 2
    assert all(math.isfinite(v) for v in gross.values())


def test_per_coin_book_returns_leverage_shape_uniform_equals_naive_and_carry_scales() -> None:
    """A uniform `leverage_shape` reproduces the naive eq-wt per-coin book EXACTLY (byte-identical net); a
    carry-weighted shape re-weights the sleeves (gross-capped at max_gross) so the net series differs. The
    measured cost model is unchanged — only the per-sleeve weighting."""
    from fxhnt.application.bybit_book_eval import _DEFAULT_BYBIT_SLEEVES

    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    _seed_momentum(store)
    _seed_carry(store)
    naive = per_coin_book_returns(store, universe=None, unlock_events=[])
    uniform = per_coin_book_returns(store, universe=None, unlock_events=[],
                                    leverage_shape={s: 1.0 for s in _DEFAULT_BYBIT_SLEEVES})
    carry = per_coin_book_returns(store, universe=None, unlock_events=[], max_gross=1.5,
                                  leverage_shape={"crypto_tstrend": 1.0, "unlock": 1.5,
                                                  "xsfunding": 5.0, "positioning": 2.0})
    store.close()
    assert uniform["net_series"] == naive["net_series"]
    assert carry["net_series"] != naive["net_series"]
    assert carry["days"]


def test_per_coin_cost_is_deterministic() -> None:
    """Re-running is identical (determinism = no hidden look-ahead state); the cost is a coin-level measured
    constant applied to each day's |Δw|."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
    a = per_coin_book_returns(store, universe=None, unlock_events=[], capital=100_000.0)
    b = per_coin_book_returns(store, universe=None, unlock_events=[], capital=100_000.0)
    store.close()
    assert a["net_series"] == b["net_series"]


def test_per_coin_net_is_below_gross_when_cost_positive() -> None:
    """With a positive cost, the net cumulative return is <= the gross cumulative return (cost is a drag)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 30_000_000.0 for s in syms + carry})
    pc = per_coin_book_returns(store, universe=None, unlock_events=[], capital=100_000.0)
    store.close()
    g = sum(pc["gross_series"].values())
    n = sum(pc["net_series"].values())
    assert n <= g + 1e-12, (g, n)


def test_per_coin_cost_is_capital_independent() -> None:
    """Because the cost is fee + measured spread (no impact term), the NET series is IDENTICAL across very
    different capital sizes — the verdict is capital-robust."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store)
    carry = _seed_carry(store)
    _seed_turnover(store, {s: 30_000_000.0 for s in syms + carry})
    small = per_coin_book_returns(store, universe=None, unlock_events=[], capital=100_000.0)
    large = per_coin_book_returns(store, universe=None, unlock_events=[], capital=50_000_000.0)
    store.close()
    assert small["net_series"] == large["net_series"]


# --- THE SWEEP -----------------------------------------------------------------------------------

def test_sweep_runs_every_bound_with_gross_and_net_oos() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store, days=400)
    carry = _seed_carry(store, days=400)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry}, days=400)
    rep = liquidity_bound_sweep(store, capital=100_000.0, unlock_events=[])
    store.close()

    assert rep["available"] is True
    table = rep["table"]
    assert set(b["bound"] for b in table) <= set(_LIQUIDITY_BOUNDS)
    for row in table:
        assert "n_coins" in row
        assert math.isfinite(row["gross_sharpe"])
        assert math.isfinite(row["net_test_sharpe"])
        assert math.isfinite(row["max_dd"])
        assert math.isfinite(row["turnover"])
        assert math.isfinite(row["median_spread_bps"])   # the auditable thin-coin penalty
    assert "verdict" in rep
    v = rep["verdict"]
    assert v["recommendation"] in set(_LIQUIDITY_BOUNDS)
    assert v["direction"] in ("CONFIRM", "WIDEN", "TIGHTEN")


def test_sweep_verdict_reacts_to_thin_coin_measured_spread() -> None:
    """The CORE proof: thin coins with a WIDE MEASURED spread that the book TRADES (high-turnover carry
    sleeve) carry a materially larger cost drag than a deep-only universe — so the broad bound's median
    measured spread >> the liquid bound's, and the NET-vs-GROSS gap widens with breadth. The verdict reacts
    to the MEASURED spread, nothing assumed."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    deep_funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
                    "XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
    thin_funding = {"PPPUSDT": 0.0012, "QQQUSDT": 0.0012, "RRRUSDT": 0.0012,
                    "SSSUSDT": -0.0012, "TTTUSDT": -0.0012, "UUUUSDT": -0.0012}
    import math as _m
    # deep coins: tight measured spread; thin coins: WIDE measured spread.
    deep_spread = 0.0004    # 4 bps
    thin_spread = 0.02      # 200 bps measured spread
    for grp, sf in ((deep_funding, deep_spread), (thin_funding, thin_spread)):
        for idx, (sym, fund) in enumerate(grp.items()):
            rows = []
            px = 100.0
            for d in range(400):
                px *= (1.0 + 0.0005 * _m.sin(0.3 * d + idx))   # small drift; spread dominates the range
                high, low = _ohlc_from_close(px, sf, vol_frac=0.0005, phase=0.3 * d + idx)
                rows.append((d * _DAY, {"funding": fund + 0.0005 * _m.cos(0.5 * d + idx),
                                        "close": px, "spot_close": 100.0,
                                        "high": high, "low": low}))
            store.write_features(sym, rows)
    dv = {s: 100_000_000.0 for s in deep_funding}
    dv.update({s: 20_000.0 for s in thin_funding})   # thin coins also fail the $vol bound
    _seed_turnover(store, dv, days=400)
    rep = liquidity_bound_sweep(store, capital=100_000.0, sleeves=["xsfunding"], unlock_events=[])
    store.close()

    assert rep["available"] is True
    by_bound = {r["bound"]: r for r in rep["table"]}
    assert 0.0 in by_bound and 10_000_000.0 in by_bound, list(by_bound)
    broad, liquid = by_bound[0.0], by_bound[10_000_000.0]
    # breadth pulls in wide-spread coins -> broad's median measured spread must EXCEED the liquid bound's.
    assert broad["median_spread_bps"] > liquid["median_spread_bps"] + 1e-9, (broad, liquid)
    # broad's NET is dragged below its own GROSS by far more than the liquid bound's (cost reacts to spread).
    broad_gap = broad["gross_sharpe"] - broad["net_full_sharpe"]
    liquid_gap = liquid["gross_sharpe"] - liquid["net_full_sharpe"]
    assert broad_gap >= liquid_gap - 1e-9, (broad_gap, liquid_gap)


def test_sweep_verdict_widens_when_broad_coins_have_tight_measured_spreads() -> None:
    """INVERSE fixture: ALL coins have a TIGHT measured spread (deep) so breadth adds diversification with
    ~zero extra cost -> the broad bound should win NET (verdict WIDEN or CONFIRM-broad, never TIGHTEN)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    momentum = _seed_momentum(store, n_symbols=12, days=400, spread_frac=0.0003)
    carry = _seed_carry(store, days=400, spread_frac=0.0003)
    _seed_turnover(store, {s: 500_000_000.0 for s in momentum + carry}, days=400)
    rep = liquidity_bound_sweep(store, capital=100_000.0, unlock_events=[])
    store.close()

    assert rep["available"] is True
    by_bound = {r["bound"]: r for r in rep["table"]}
    if 0.0 in by_bound:
        broad = by_bound[0.0]
        assert broad["median_spread_bps"] < 20.0, broad   # tight -> small spread -> small drag
    assert rep["verdict"]["direction"] in ("WIDEN", "CONFIRM"), rep["verdict"]


def test_sweep_reports_capital_and_median_spread() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store, days=400)
    carry = _seed_carry(store, days=400)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry}, days=400)
    rep = liquidity_bound_sweep(store, capital=100_000.0, unlock_events=[])
    store.close()
    assert rep["capital"] == 100_000.0
    assert rep["taker_fee_bps"] == 5.5
    # the gross Sharpe + median measured spread per bound are reported for audit.
    assert all("gross_sharpe" in r for r in rep["table"])
    assert all("median_spread_bps" in r for r in rep["table"])


def test_sweep_verdict_is_capital_robust() -> None:
    """Because the measured cost is capital-independent in bps, the verdict + per-bound NET Sharpes are
    identical across very different capitals (the bonus of dropping the impact term)."""
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(store, days=400)
    carry = _seed_carry(store, days=400)
    _seed_turnover(store, {s: 50_000_000.0 for s in syms + carry}, days=400)
    small = liquidity_bound_sweep(store, capital=100_000.0, unlock_events=[])
    large = liquidity_bound_sweep(store, capital=50_000_000.0, unlock_events=[])
    store.close()
    assert small["verdict"]["recommendation"] == large["verdict"]["recommendation"]
    sb = {r["bound"]: r["net_test_sharpe"] for r in small["table"]}
    lb = {r["bound"]: r["net_test_sharpe"] for r in large["table"]}
    assert sb == lb


# --- READ-ONLY + memory-bound --------------------------------------------------------------------

def test_sweep_is_read_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(inner)
    carry = _seed_carry(inner)
    _seed_turnover(inner, {s: 50_000_000.0 for s in syms + carry})
    guarded = _WriteTripwireStore(inner)
    rep = liquidity_bound_sweep(guarded, capital=100_000.0, unlock_events=[])
    inner.close()
    assert rep["available"] is True


def test_sweep_universe_restricted_reads_only() -> None:
    inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(inner, n_symbols=8)
    _seed_turnover(inner, {s: 50_000_000.0 for s in syms})
    guarded = _FullPanelTripwireStore(inner)
    universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT", "SYM06USDT"}
    per_coin_book_returns(guarded, universe=universe, unlock_events=[], capital=100_000.0)
    inner.close()
    assert guarded.read_panel_calls
    for symbols, _feature in guarded.read_panel_calls:
        assert set(symbols) <= universe


def test_sweep_empty_store_reports_unavailable() -> None:
    store = TimescaleFeatureStore("sqlite://", table="bybit_features")
    rep = liquidity_bound_sweep(store, unlock_events=[])
    store.close()
    assert rep["available"] is False
    assert "reason" in rep


# --- CLI smoke (mocked store) --------------------------------------------------------------------

def test_cli_bybit_liquidity_sweep_prints_table(monkeypatch) -> None:
    from typer.testing import CliRunner

    import fxhnt.cli as cli

    seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
    syms = _seed_momentum(seeded, n_symbols=8, days=400)
    carry = _seed_carry(seeded, days=400)
    _seed_turnover(seeded, {s: 50_000_000.0 for s in syms + carry}, days=400)

    monkeypatch.setattr(
        "fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
        lambda *a, **k: seeded, raising=False)

    result = CliRunner().invoke(
        cli.app, ["bybit-liquidity-sweep", "--capital", "100000"])
    seeded.close()

    assert result.exit_code == 0, result.output
    out_lower = result.output.lower()
    assert "bound" in out_lower
    assert "gross" in out_lower and "net" in out_lower
    assert "spread" in out_lower   # the measured spread column
    assert "verdict" in out_lower
    assert "confirm" in out_lower or "widen" in out_lower or "tighten" in out_lower
