"""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. READ-ONLY,
memory-bounded.

WHY
---
We DEFAULT the Bybit book to the liquid subset (`--min-dollar-vol 10_000_000` → ~61 coins) on the ASSERTED
prior that illiquid coins cost too much to trade. This harness MEASURES it — but, crucially, with a cost that
has NO free parameters. A previous version charged `base + impact_coef·sqrt(order/volume)`; the `impact_coef`
was an ASSUMPTION (no Bybit-fitted constant) — unacceptable for a trading decision. It is GONE.

THE MEASURED COST (no free parameters — every input is a published fee or a data-measured spread)
------------------------------------------------------------------------------------------------
    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. (Documented here; it is a real, quoted cost every taker pays.)
  * `effective_spread_bps(coin)` — MEASURED from real OHLC via the Corwin–Schultz (2012) high-low spread
    estimator, a published PARAMETER-FREE estimator that derives the effective bid-ask spread from two
    consecutive days' HIGH/LOW. We have `high`, `low`, `close` in `bybit_features`. We compute it per coin as
    the MEDIAN of the daily CS estimates (robust central tendency, mirroring `liquid_universe`'s median
    discipline) and clamp negative estimates to 0 (as the estimator prescribes). You cross HALF the measured
    spread per trade (the half-spread crossing cost), hence the `0.5·` factor.
  * MARKET IMPACT IS DROPPED ENTIRELY — and DROPPED, not assumed-zero-with-a-guess. Impact cannot be measured
    without live execution data, and at the ~$100k deploy-book size each per-coin order is a tiny fraction of
    a coin's daily volume, so impact is negligible; we therefore do NOT model it. A future LARGER book that
    needs an impact term must fit it from REAL fills, not a guessed coefficient.

  So the cost is a KNOWN fee + a DATA-MEASURED spread. Thin coins have genuinely wider MEASURED spreads →
  higher cost — that is the real, observed driver, not an assumption. A useful BONUS: because the cost is a
  per-bp quantity independent of order size, the verdict is now CAPITAL-ROBUST (capital only changes the
  notional, never the per-bp cost).

THE SWEEP
---------
For each bound in `_LIQUIDITY_BOUNDS` (0 = broad/all, 5M, 10M = current default/liquid-61, 25M, 50M):
  * universe = the coins clearing the bound (`bybit_liquidity.liquid_universe`, the SAME path the book uses);
  * build the naive eq-wt 4-edge book's per-COIN daily returns (`per_coin_book_returns`), REUSING
    `bybit_paper_weights.sleeve_weights_and_contributions` so the per-coin returns reconcile with the live
    book's edges (combine eq-wt across the 4 sleeves per coin);
  * each coin's MEASURED effective spread (Corwin–Schultz on its high/low) → its measured cost in bps;
  * GROSS daily return = Σ_coin w·ret;  NET = GROSS − Σ_coin |Δw|·cost_bps(coin)/1e4;
  * metrics: GROSS Sharpe (no cost), NET Sharpe (measured cost), CAGR, vol, maxDD, turnover, n_coins, and the
    MEDIAN measured effective-spread (bps) of the coins in that bound (so the thin-coin penalty is visible +
    auditable).

OOS DISCIPLINE (same gauntlet as `sleeve_weighting_ab`)
------------------------------------------------------
TRAIN/TEST split + non-overlapping rolling fraction-positive. The VERDICT is the bound with the best
TEST/OOS NET Sharpe. The GROSS Sharpe per bound is also reported so the OVER-DIVERSIFICATION artifact is
visible (the carry showed 13.94 vs 0.63 = spurious diversification on thin, correlated small-caps): if broad's
GROSS balloons vs liquid, that is the flag, and the NET-of-measured-cost number is the real verdict.

REUSE: `bybit_paper_weights.sleeve_weights_and_contributions` (per-symbol weights of the live book) +
`bybit_liquidity.liquid_universe` (the dollar-volume path `--min-dollar-vol` already uses) +
`bybit_book_eval._bounded_store` (the `_UniverseRestrictedStore` memory seam) + the `sleeve_weighting_ab`
OOS split / rolling-window pattern. READ-ONLY: no PaperRepo, no `store.write_*` (a WriteTripwire test proves
it). MEMORY-BOUNDED: a `universe` restricts the panel reads to the liquid subset (a FullPanel tripwire test
proves it).
"""
from __future__ import annotations

import math
import statistics as st
from typing import Any, Protocol

from fxhnt.application.bybit_book_eval import _DEFAULT_BYBIT_SLEEVES, _bounded_store
from fxhnt.application.bybit_paper_weights import sleeve_weights_and_contributions

_PERIODS_PER_YEAR = 365

# The liquidity bounds (min daily $-volume) to sweep. 0 = broad/all; 10M = the current default (liquid-61).
_LIQUIDITY_BOUNDS: tuple[float, ...] = (0.0, 5_000_000.0, 10_000_000.0, 25_000_000.0, 50_000_000.0)

# Bybit's PUBLISHED perp taker fee (0.055% = 5.5 bps). A KNOWN exchange fee, not an assumption. Every taker
# pays this; it is the floor cost a perfectly-deep coin still incurs.
_TAKER_FEE_BPS = 5.5

# Corwin–Schultz (2012) constants. The estimator is PARAMETER-FREE; these are the published algebraic
# constants of the closed form, not tunable knobs.
_CS_K = 3.0 - 2.0 * math.sqrt(2.0)   # = 3 - 2√2

# A book sleeve must contribute >= this many days to count. Rolling-window length for fraction-positive.
_ROLLING_WINDOW_DAYS = 60
_MIN_WINDOW_FRAC = 0.50
# A tighter bound must beat the 10M default's TEST/OOS NET Sharpe by this margin to be "better" (noise guard).
_BETTER_MARGIN = 0.10

_DEFAULT_BOUND = 10_000_000.0   # the current live default / liquid-61


class _FeatureStore(Protocol):
    def crypto_close_panel(self, *, suffix: str = "USDT") -> dict[str, dict[int, float]]: ...
    def crypto_funding_panel(self, *, suffix: str = "USDT") -> dict[str, dict[int, float]]: ...
    def crypto_spot_panel(self, *, suffix: str = "USDT") -> dict[str, dict[int, float]]: ...
    def read_panel(self, symbols: list[str], feature: str) -> dict[str, dict[int, float]]: ...


# --- THE MEASURED SPREAD (Corwin–Schultz 2012, parameter-free) -----------------------------------

def corwin_schultz_spread_bps(highs: list[float], lows: list[float]) -> float:
    """The MEASURED effective bid-ask spread (bps) from a series of daily HIGHs and LOWs via the
    Corwin–Schultz (2012) high-low estimator — a published, PARAMETER-FREE estimator.

    For each pair of consecutive days (t, t+1):
        β = [ln(H_t / L_t)]² + [ln(H_{t+1} / L_{t+1})]²        (single-day high-low: spread + 1-day vol)
        γ = [ln(max(H_t,H_{t+1}) / min(L_t,L_{t+1}))]²         (2-day high-low: spread + 2-day vol)
        α = (√(2β) − √β) / (3 − 2√2)  −  √(γ / (3 − 2√2))
        S = 2(e^α − 1) / (1 + e^α)                              (the spread as a proportion of price)
    The algebra cancels the volatility term, leaving the spread. We take the MEDIAN of the per-pair S over
    the series (robust central tendency), clamp NEGATIVE per-pair estimates to 0 (as the estimator prescribes
    — negatives arise from estimation noise / overlapping ranges and signify ≈ zero spread), and return
    S_median × 1e4 in bps. Empty / degenerate input (no valid high>low pair) → 0.0. Pure, deterministic."""
    n = min(len(highs), len(lows))
    if n < 2:
        return 0.0
    estimates: list[float] = []
    for t in range(n - 1):
        h1, l1, h2, l2 = highs[t], lows[t], highs[t + 1], lows[t + 1]
        if h1 <= 0.0 or l1 <= 0.0 or h2 <= 0.0 or l2 <= 0.0 or h1 < l1 or h2 < l2:
            continue
        hl1 = math.log(h1 / l1)
        hl2 = math.log(h2 / l2)
        beta = hl1 * hl1 + hl2 * hl2
        hi2 = max(h1, h2)
        lo2 = min(l1, l2)
        if hi2 <= 0.0 or lo2 <= 0.0 or hi2 < lo2:
            continue
        g = math.log(hi2 / lo2)
        gamma = g * g
        alpha = (math.sqrt(2.0 * beta) - math.sqrt(beta)) / _CS_K - math.sqrt(gamma / _CS_K)
        spread = 2.0 * (math.exp(alpha) - 1.0) / (1.0 + math.exp(alpha))
        # The estimator prescribes clamping negative per-pair estimates to 0 (noise → ≈ zero spread).
        estimates.append(max(0.0, spread))
    if not estimates:
        return 0.0
    return float(st.median(estimates) * 1e4)


# --- THE MEASURED COST (no free parameters) ------------------------------------------------------

def coin_cost_bps(*, effective_spread_bps: float, taker_fee_bps: float = _TAKER_FEE_BPS) -> float:
    """Per-coin per-leg trading cost (bps) from MEASURED / KNOWN quantities ONLY:

        cost = taker_fee_bps + 0.5 · effective_spread_bps

    `taker_fee_bps` is Bybit's PUBLISHED perp taker fee (a known exchange fee). `effective_spread_bps` is the
    coin's MEASURED Corwin–Schultz spread; you cross HALF of it per trade. A deep coin (≈ zero measured
    spread) → cost ≈ the fee floor; a thin coin (wide measured spread) → cost >> the fee. Monotone in the
    measured spread; CAPITAL-INDEPENDENT (no order-size / impact term); deterministic. A zero / negative
    measured spread cannot drag the cost below the fee floor. NO assumed market-impact coefficient."""
    half_spread = 0.5 * max(0.0, effective_spread_bps)
    return float(taker_fee_bps + half_spread)


# --- PER-COIN BOOK ASSEMBLY (reuses the live per-symbol weights) ---------------------------------

def _coin_spread_bps(store: _FeatureStore, universe: set[str] | None,
                     symbols: list[str]) -> dict[str, float]:
    """The per-coin MEASURED effective spread (bps) — Corwin–Schultz on each coin's daily HIGH/LOW, read via
    the universe-restricted `read_panel` (memory-bounded `WHERE symbol IN (...)`). A coin missing high/low (or
    with no valid high>low pair) measures 0.0 → it then pays only the known taker fee, never a guessed
    penalty. Days are paired in chronological order so consecutive-day high/low feed β/γ correctly."""
    if not symbols:
        return {}
    syms = sorted(symbols)
    high_panel = store.read_panel(syms, "high")
    low_panel = store.read_panel(syms, "low")
    out: dict[str, float] = {}
    for sym in syms:
        hi = high_panel.get(sym, {})
        lo = low_panel.get(sym, {})
        common_days = sorted(set(hi) & set(lo))
        if len(common_days) < 2:
            out[sym] = 0.0
            continue
        highs = [hi[d] for d in common_days]
        lows = [lo[d] for d in common_days]
        out[sym] = corwin_schultz_spread_bps(highs, lows)
    return out


def per_coin_book_returns(store: _FeatureStore, *, universe: set[str] | None,
                          sleeves: list[str] | None = None, capital: float = 100_000.0,
                          taker_fee_bps: float = _TAKER_FEE_BPS,
                          unlock_events: list[dict[str, Any]] | None = None,
                          stablecoin_spot_panel: dict[str, dict[int, float]] | None = None,
                          return_detail: bool = False,
                          leverage_shape: dict[str, float] | None = None,
                          max_gross: float = 1.5,
                          ) -> dict[str, Any]:
    """The naive eq-wt 4-edge book's per-COIN daily GROSS and NET (measured-cost) return series.

    For each book sleeve, `sleeve_weights_and_contributions` gives `{day: {symbol: (weight, ret)}}` — the
    EXACT per-symbol weights/returns of the live book (so this reconciles with the live edges, no drift). The
    4 sleeves are combined EQ-WT (each sleeve = 1/n_sleeves of capital), so a coin's book weight on day d is
    `Σ_sleeve (1/n_sleeves)·w_sleeve(coin,d)` and its gross contribution is that × the sleeve's per-coin ret.

    GROSS daily return = Σ_coin w·ret. The per-coin trading cost is charged on the coin's |Δweight| at the
    coin's MEASURED cost `coin_cost_bps` (taker fee + half the Corwin–Schultz measured spread). The cost is
    CAPITAL-INDEPENDENT in bps (no impact term), so `capital` affects only the reported notional, never the
    per-bp cost. NET = GROSS − Σ_coin |Δw|·cost/1e4.

    Returns `{available, days, n_coins, gross_series, net_series, turnover (Σ|Δw|), avg_cost_drag_bps,
    median_spread_bps}`. When `return_detail=True`, ALSO returns `spread_by_coin` ({coin: measured CS spread
    bps}) and `daily_turnover` ({day: {coin: |Δw|}}, only the days/coins that traded) — the execution-cost
    internals a re-costing (e.g. maker-vs-taker) consumes WITHOUT re-deriving the book: the GROSS series and
    the per-day per-coin turnover are fixed; only the per-turnover cost model changes. The extra keys do NOT
    alter the existing ones, so the all-taker net is byte-identical with or without the flag. READ-ONLY (only
    panel READS); memory-bounded by `universe`. A genuinely-uncomputable sleeve (no data / missing input)
    contributes nothing rather than fabricating returns."""
    candidate = list(sleeves) if sleeves is not None else list(_DEFAULT_BYBIT_SLEEVES)
    n_sleeves = len(candidate)

    # MEMORY BOUND: wrap the store ONCE so EVERY sleeve's panel reads (incl. the carry path, which calls
    # crypto_close_panel() directly) go through the `_UniverseRestrictedStore` seam (a WHERE symbol IN (...)
    # read). Pass universe=None downstream since the wrapped store is already restricted (avoids a redundant
    # re-intersect; for tstrend `_bounded_store(bounded, None)` returns the already-restricted store).
    bounded = _bounded_store(store, universe)

    # 1) per-sleeve per-coin (weight, ret) by day, from the LIVE per-symbol weights.
    per_sleeve: dict[str, dict[int, dict[str, tuple[float, float]]]] = {}
    for sleeve in candidate:
        w = sleeve_weights_and_contributions(bounded, sleeve, universe=None, unlock_events=unlock_events)
        if w:
            per_sleeve[sleeve] = w
    if not per_sleeve:
        return {"available": False, "days": 0, "n_coins": 0, "gross_series": {}, "net_series": {},
                "turnover": 0.0, "avg_cost_drag_bps": 0.0, "median_spread_bps": 0.0,
                "reason": "no Bybit book sleeve has per-coin weights (run the Phase-A/B ingests first)"}

    # 2) combine across sleeves into per-coin book weight + gross contribution, by day. Each sleeve gets a
    # factor: naive = 1/n_sleeves (eq-wt); with a `leverage_shape`, factor = shape[sleeve]/n_sleeves, then all
    # factors are scaled down so the summed sleeve gross <= max_gross (the SAME gross-capped weighting as
    # `levered_eqwt_daily_returns`, applied at the per-coin weight layer so the measured cost model is unchanged).
    if leverage_shape is None:
        factor = {s: 1.0 / n_sleeves for s in per_sleeve}
    else:
        factor = {s: leverage_shape.get(s, 1.0) / n_sleeves for s in per_sleeve}
        gross = sum(abs(v) for v in factor.values())
        if gross > max_gross and gross > 0:
            scale = max_gross / gross
            factor = {s: v * scale for s, v in factor.items()}
    all_days = sorted({d for w in per_sleeve.values() for d in w})
    coin_weight: dict[int, dict[str, float]] = {}      # day -> {coin: book weight}
    coin_contrib: dict[int, dict[str, float]] = {}     # day -> {coin: book gross contribution (w·r)}
    coins_seen: set[str] = set()
    for d in all_days:
        wrow: dict[str, float] = {}
        crow: dict[str, float] = {}
        for sleeve, by_day in per_sleeve.items():
            f = factor[sleeve]
            for coin, (w, r) in by_day.get(d, {}).items():
                wrow[coin] = wrow.get(coin, 0.0) + f * w
                crow[coin] = crow.get(coin, 0.0) + f * w * r
                coins_seen.add(coin)
        coin_weight[d] = wrow
        coin_contrib[d] = crow

    # 3) the per-coin MEASURED effective spread (Corwin–Schultz on high/low) → per-coin measured cost (bps).
    spread_bps = _coin_spread_bps(store, universe, sorted(coins_seen))
    cost_by_coin = {c: coin_cost_bps(effective_spread_bps=spread_bps.get(c, 0.0),
                                     taker_fee_bps=taker_fee_bps) for c in coins_seen}

    # 4) walk the days: gross = Σ contrib; cost = Σ_coin |Δw|·cost_bps(coin)/1e4 (capital-independent in bps).
    gross_series: dict[int, float] = {}
    net_series: dict[int, float] = {}
    daily_turnover: dict[int, dict[str, float]] = {}   # day -> {coin: |Δw|} (only for return_detail)
    prev_w: dict[str, float] = {}
    turnover_sum = 0.0
    cost_bps_weighted_sum = 0.0   # Σ (|Δw| · cost_bps) — for the turnover-weighted avg drag
    traded_w_sum = 0.0
    for d in all_days:
        gross = sum(coin_contrib[d].values())
        wrow = coin_weight[d]
        day_cost = 0.0
        coins = set(wrow) | set(prev_w)
        day_turn: dict[str, float] = {}
        for coin in coins:
            dw = abs(wrow.get(coin, 0.0) - prev_w.get(coin, 0.0))
            if dw <= 0.0:
                continue
            turnover_sum += dw
            cbps = cost_by_coin.get(coin, taker_fee_bps)
            day_cost += dw * cbps / 1e4
            cost_bps_weighted_sum += dw * cbps
            traded_w_sum += dw
            day_turn[coin] = dw
        if math.isfinite(gross):
            gross_series[d] = gross
            net_series[d] = gross - day_cost
            if return_detail:
                daily_turnover[d] = day_turn
        prev_w = dict(wrow)

    avg_cost_drag_bps = (cost_bps_weighted_sum / traded_w_sum) if traded_w_sum > 0 else 0.0
    measured_spreads = [spread_bps[c] for c in sorted(coins_seen) if c in spread_bps]
    median_spread_bps = float(st.median(measured_spreads)) if measured_spreads else 0.0
    result = {"available": len(net_series) >= 2, "days": len(net_series), "n_coins": len(coins_seen),
              "gross_series": gross_series, "net_series": net_series, "turnover": turnover_sum,
              "avg_cost_drag_bps": avg_cost_drag_bps, "median_spread_bps": median_spread_bps,
              "capital": capital}
    if return_detail:
        # The execution-cost internals for a re-costing: the per-coin MEASURED spread and the per-day per-coin
        # turnover. GROSS and these turnovers are FIXED; a re-costing only swaps the per-bp cost model.
        result["spread_by_coin"] = dict(spread_bps)
        result["daily_turnover"] = daily_turnover
    return result


# --- metrics + OOS split + rolling windows (mirrors sleeve_weighting_ab) -------------------------

def _metrics(series: dict[int, float]) -> dict[str, Any]:
    days = sorted(series)
    rets = [series[d] for d in days]
    if len(rets) < 2:
        return {"sharpe": 0.0, "cagr": 0.0, "max_dd": 0.0, "vol": 0.0, "total_return": 0.0,
                "days": len(rets)}
    sd = st.pstdev(rets)
    sharpe = (st.mean(rets) / sd * math.sqrt(_PERIODS_PER_YEAR)) if sd > 0 else 0.0
    vol = sd * math.sqrt(_PERIODS_PER_YEAR)
    equity = 1.0
    peak = 1.0
    max_dd = 0.0
    for r in rets:
        equity *= (1.0 + r)
        peak = max(peak, equity)
        if peak > 0:
            max_dd = min(max_dd, equity / peak - 1.0)
    span = days[-1] - days[0]
    years = span / _PERIODS_PER_YEAR if span > 0 else 0.0
    cagr = (equity ** (1.0 / years) - 1.0) if years > 0 and equity > 0 else 0.0
    return {"sharpe": sharpe, "cagr": cagr, "max_dd": max_dd, "vol": vol,
            "total_return": equity - 1.0, "days": len(rets)}


def _split_day(days: list[int], train_frac: float) -> int:
    """The first TEST day (inclusive). Returns `days[idx]` where idx = train_frac of the date axis."""
    if len(days) < 4:
        return (days[-1] + 1) if days else 1
    idx = max(1, min(len(days) - 1, int(len(days) * train_frac)))
    return days[idx]


def _window(series: dict[int, float], *, day_lo: int | None = None,
            day_hi: int | None = None) -> dict[int, float]:
    return {d: r for d, r in series.items()
            if (day_lo is None or d >= day_lo) and (day_hi is None or d <= day_hi)}


def _rolling_fraction_positive(series: dict[int, float], *, window_days: int) -> float:
    if not series:
        return 0.0
    days = sorted(series)
    lo, hi = days[0], days[-1]
    n_pos = n_win = 0
    start = lo
    while start <= hi:
        end = start + window_days - 1
        rets = [series[d] for d in days if start <= d <= end]
        if len(rets) >= 2:
            n_win += 1
            tot = 1.0
            for r in rets:
                tot *= (1.0 + r)
            if tot - 1.0 > 0.0:
                n_pos += 1
        start = end + 1
    return (n_pos / n_win) if n_win else 0.0


# --- the sweep ----------------------------------------------------------------------------------

def _bound_row(store: _FeatureStore, *, bound: float, capital: float, taker_fee_bps: float,
               train_frac: float, sleeves: list[str] | None,
               unlock_events: list[dict[str, Any]] | None,
               stablecoin_spot_panel: dict[str, dict[int, float]] | None,
               min_dollar_vol_resolver) -> dict[str, Any] | None:
    """One bound's row: resolve the universe, build the per-coin book, split TRAIN/TEST, compute GROSS +
    NET-OOS Sharpe + CAGR/vol/maxDD/turnover/cost-drag + the median MEASURED spread + rolling
    fraction-positive. None if the universe is empty (skipped from the table). bound 0.0 → broad
    (universe=None, the full store)."""
    universe = None if bound <= 0.0 else min_dollar_vol_resolver(bound)
    if bound > 0.0 and not universe:
        return None
    pc = per_coin_book_returns(
        store, universe=universe, sleeves=sleeves, capital=capital, taker_fee_bps=taker_fee_bps,
        unlock_events=unlock_events, stablecoin_spot_panel=stablecoin_spot_panel)
    if not pc.get("available"):
        return None

    gross, net = pc["gross_series"], pc["net_series"]
    days = sorted(net)
    test_lo = _split_day(days, train_frac)

    gross_m = _metrics(gross)
    net_full = _metrics(net)
    net_train = _metrics(_window(net, day_hi=test_lo - 1))
    net_test = _metrics(_window(net, day_lo=test_lo))
    return {
        "bound": bound,
        "n_coins": pc["n_coins"],
        "gross_sharpe": gross_m["sharpe"],
        "net_full_sharpe": net_full["sharpe"],
        "net_train_sharpe": net_train["sharpe"],
        "net_test_sharpe": net_test["sharpe"],
        "cagr": net_test["cagr"],
        "vol": net_test["vol"],
        "max_dd": net_test["max_dd"],
        "turnover": pc["turnover"],
        "avg_cost_drag_bps": pc["avg_cost_drag_bps"],
        "median_spread_bps": pc["median_spread_bps"],
        "rolling_frac_positive": _rolling_fraction_positive(net, window_days=_ROLLING_WINDOW_DAYS),
    }


def _verdict(table: list[dict[str, Any]], *, min_window_frac: float) -> dict[str, Any]:
    """The bound with the best TEST/OOS NET Sharpe (must clear the rolling-window bar). Direction is relative
    to the live 10M default: CONFIRM if the winner IS 10M (or no qualifier clears the margin), WIDEN if the
    winner is a LOOSER bound (< 10M), TIGHTEN if TIGHTER (> 10M). The over-diversification flag fires when
    the broad (smallest) bound's GROSS Sharpe is much larger than the 10M default's GROSS."""
    qualified = [r for r in table if r["rolling_frac_positive"] >= min_window_frac]
    pool = qualified if qualified else table
    if not pool:
        return {"recommendation": _DEFAULT_BOUND, "direction": "CONFIRM",
                "note": "no bound produced a usable book", "over_diversification_flag": False}

    best = max(pool, key=lambda r: r["net_test_sharpe"])
    default_row = next((r for r in table if r["bound"] == _DEFAULT_BOUND), None)
    default_net = default_row["net_test_sharpe"] if default_row else -math.inf

    rec = best["bound"]
    # Only move OFF the default if the winner beats the default's OOS NET by a margin (noise guard).
    if rec != _DEFAULT_BOUND and best["net_test_sharpe"] <= default_net + _BETTER_MARGIN:
        rec = _DEFAULT_BOUND
        best = default_row if default_row else best

    if rec == _DEFAULT_BOUND:
        direction = "CONFIRM"
    elif rec < _DEFAULT_BOUND:
        direction = "WIDEN"
    else:
        direction = "TIGHTEN"

    # over-diversification: broad GROSS Sharpe >> default GROSS Sharpe = spurious thin-coin diversification.
    broad_row = min(table, key=lambda r: r["bound"])
    over_div = False
    if default_row and broad_row["bound"] < _DEFAULT_BOUND:
        gd = default_row["gross_sharpe"]
        over_div = gd > 0 and broad_row["gross_sharpe"] > gd * 1.5

    note_dir = {"CONFIRM": f"the ${_DEFAULT_BOUND:,.0f} default (liquid-61) is ~optimal NET of measured cost",
                "WIDEN": f"a LOOSER bound (${rec:,.0f}) beats the ${_DEFAULT_BOUND:,.0f} default OOS NET",
                "TIGHTEN": f"a TIGHTER bound (${rec:,.0f}) beats the ${_DEFAULT_BOUND:,.0f} default OOS NET"}[direction]
    note = (f"{direction}: {note_dir} (best TEST/OOS NET Sharpe {best['net_test_sharpe']:.2f}). "
            + ("OVER-DIVERSIFICATION FLAG: the broad universe's GROSS Sharpe is >1.5× the default's — the "
               "extra gross is spurious thin-coin diversification, which the measured wider-spread cost "
               "erodes. " if over_div else "")
            + "Cost = Bybit's published taker fee + half the Corwin–Schultz MEASURED spread; NO assumed "
              "impact coefficient, so the verdict is capital-robust.")
    return {"recommendation": rec, "direction": direction, "best_net_test_sharpe": best["net_test_sharpe"],
            "default_net_test_sharpe": default_net, "over_diversification_flag": over_div, "note": note}


def liquidity_bound_sweep(store: _FeatureStore, *, capital: float = 100_000.0,
                          taker_fee_bps: float = _TAKER_FEE_BPS, train_frac: float = 0.6,
                          lookback_days: int = 90, sleeves: list[str] | None = None,
                          min_window_frac: float = _MIN_WINDOW_FRAC,
                          unlock_events: list[dict[str, Any]] | None = None,
                          stablecoin_spot_panel: dict[str, dict[int, float]] | None = None,
                          ) -> dict[str, Any]:
    """Sweep the liquidity bound (`_LIQUIDITY_BOUNDS`) for the Bybit 4-edge book and find the OPTIMAL
    `--min-dollar-vol` NET of a MEASURED cost (published taker fee + half the Corwin–Schultz measured spread;
    NO assumed impact coefficient). READ-ONLY, memory-bounded.

    For each bound: universe = `liquid_universe(store, min_dollar_vol=bound)` (bound 0 = broad/all), build the
    per-coin eq-wt 4-edge book (`per_coin_book_returns`), split TRAIN/TEST, and report GROSS Sharpe (no
    cost), NET TRAIN/TEST/FULL Sharpe (measured cost), CAGR, vol, maxDD, turnover, n_coins, and the MEDIAN
    measured effective-spread (bps) of the coins in that bound (the auditable thin-coin penalty). VERDICT =
    the bound with the best TEST/OOS NET Sharpe, stated as CONFIRM (the 10M default) / WIDEN / TIGHTEN, with
    the over-diversification flag.

    Returns `{available, capital, taker_fee_bps, train_frac, table [per-bound rows], verdict}`.
    Writes NOTHING. Because the cost is capital-independent per-bp, the verdict is capital-robust."""
    # Lazy import so the pure cost-model tests do not need the liquidity module / store.
    from fxhnt.application.bybit_liquidity import liquid_universe

    def _resolve(bound: float) -> set[str]:
        return liquid_universe(store, min_dollar_vol=bound, lookback_days=lookback_days)

    table: list[dict[str, Any]] = []
    for bound in _LIQUIDITY_BOUNDS:
        row = _bound_row(
            store, bound=bound, capital=capital, taker_fee_bps=taker_fee_bps, train_frac=train_frac,
            sleeves=sleeves, unlock_events=unlock_events,
            stablecoin_spot_panel=stablecoin_spot_panel, min_dollar_vol_resolver=_resolve)
        if row is not None:
            table.append(row)

    if not table:
        return {"available": False, "capital": capital, "taker_fee_bps": taker_fee_bps,
                "train_frac": train_frac, "table": [],
                "reason": "no liquidity bound produced a usable per-coin book (run the Phase-A/B Bybit "
                          "ingests first; the high/low + turnover features are needed for the cost model)"}

    verdict = _verdict(table, min_window_frac=min_window_frac)
    return {"available": True, "capital": capital, "taker_fee_bps": taker_fee_bps,
            "train_frac": train_frac, "min_window_frac": min_window_frac, "table": table,
            "verdict": verdict}
