diff --git a/src/fxhnt/adapters/persistence/paper_repo.py b/src/fxhnt/adapters/persistence/paper_repo.py
index a35336e..821be85 100644
--- a/src/fxhnt/adapters/persistence/paper_repo.py
+++ b/src/fxhnt/adapters/persistence/paper_repo.py
@@ -197,6 +197,25 @@ class PaperRepo:
.order_by(PaperShadowPositionRow.sleeve, PaperShadowPositionRow.symbol))
return [Position(r.sleeve, r.symbol, r.qty, r.entry_price, r.entry_date) for r in rows]
+ def shadow_run_dates(self) -> list[str]:
+ """Every run_date with a UNIT shadow book, ascending (ISO strings) — the date axis the per-coin
+ compare walks to surface within-sleeve weights. READ-ONLY."""
+ with Session(self._engine) as s:
+ rows = s.scalars(select(PaperShadowPositionRow.run_date)
+ .where(PaperShadowPositionRow.venue == self._venue)
+ .distinct().order_by(PaperShadowPositionRow.run_date.asc()))
+ return [r.isoformat() for r in rows]
+
+ def shadow_positions_on(self, run_date: str) -> list[Position]:
+ """The UNIT shadow book persisted for EXACTLY `run_date` (sleeve+symbol ordered). READ-ONLY."""
+ rd = dt.date.fromisoformat(run_date)
+ with Session(self._engine) as s:
+ rows = s.scalars(select(PaperShadowPositionRow)
+ .where(PaperShadowPositionRow.venue == self._venue,
+ PaperShadowPositionRow.run_date == rd)
+ .order_by(PaperShadowPositionRow.sleeve, PaperShadowPositionRow.symbol))
+ return [Position(r.sleeve, r.symbol, r.qty, r.entry_price, r.entry_date) for r in rows]
+
def weight_for(self, sleeve: str) -> float:
with Session(self._engine) as s:
rd = self._latest_run_date(s)
diff --git a/src/fxhnt/adapters/web/app.py b/src/fxhnt/adapters/web/app.py
index 1cbc943..d86fcdc 100644
--- a/src/fxhnt/adapters/web/app.py
+++ b/src/fxhnt/adapters/web/app.py
@@ -31,7 +31,8 @@ def create_app(repo: ForwardNavRepo, svc: DashboardReadModel | None = None,
paper_repo: PaperRepo | None = None,
live: LivePrice | None = None,
bybit_paper_repo: PaperRepo | None = None,
- bybit_live: LivePrice | None = None) -> FastAPI:
+ bybit_live: LivePrice | None = None,
+ feature_store: Any | None = None) -> FastAPI:
app = FastAPI(title="fxhnt cockpit")
app.mount("/static", StaticFiles(directory=_HERE / "static"), name="static")
templates = Jinja2Templates(directory=str(_HERE / "templates"))
@@ -371,80 +372,45 @@ def create_app(repo: ForwardNavRepo, svc: DashboardReadModel | None = None,
"now": _now(),
}
- def _book_first_last(returns: dict[str, dict[int, float]]) -> tuple[int | None, int | None]:
- """The (first, last) epoch-day present across all sleeves of a returns table, or (None, None)."""
- days = [d for r in returns.values() for d in r]
- return (min(days), max(days)) if days else (None, None)
-
- def _compare_summary(book: str, label: str, res: Any, capital: float) -> dict[str, Any]:
- """One row of the side-by-side compare table for a single book's SimResult over the shared window:
- Sharpe, CAGR, total return %, maxDD, final equity, n_days. data-* attrs carry machine-readable
- final/start equity so a test (and any client) can read them without parsing formatted text."""
- m = res.metrics
- n_days = len(res.dates)
- # Both curves compound from `capital` on day 1, so the honest "start equity" for the comparison is
- # the configured capital itself — both books are normalized to the SAME base.
- return {
- "book": book,
- "label": label,
- "sharpe": m.get("sharpe", 0.0),
- "cagr": m.get("cagr", 0.0),
- "total_return": m.get("total_return", 0.0),
- "max_dd": m.get("max_dd", 0.0),
- "end_value": m.get("end_value", capital),
- "n_days": n_days,
- "start_equity": capital,
- }
+ def _binance_feature_store() -> Any | None:
+ """The Binance warehouse feature store (`features` table) the compare reads per-coin high/low from
+ for the MEASURED Corwin–Schultz spread. The injected store (tests) wins; else build one from
+ settings, returning None on any failure so the compare degrades to the fee-floor cost (never 500)."""
+ if feature_store is not None:
+ return feature_store
+ try:
+ from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
+ from fxhnt.config import get_settings
+ return TimescaleFeatureStore(get_settings().operational_dsn, table="features")
+ except Exception: # noqa: BLE001 — a missing warehouse must not break the page
+ return None
def _compare_context(*, capital: float, start: str | None, end: str | None,
cost_bps: float) -> dict[str, Any]:
- """COMPARE mode: run BOTH books over the SAME window + SAME cost, overlaid, with a side-by-side
- metrics table — so Binance vs Bybit is apples-to-apples (the live equity figures are misleading).
+ """COMPARE mode: run BOTH books over the OVERLAP window with a per-COIN MEASURED cost, overlaid,
+ with a side-by-side metrics table — apples-to-apples AND de-inflated (the old flat cost flattered
+ Binance's broad/illiquid universe; now each coin pays its real Corwin–Schultz-measured spread).
- Window default = the OVERLAP: start = max(first_binance, first_bybit), end = min(last_*), so both
- books have data the whole window. The user's explicit start/end override. Both books are charged the
- SAME `cost_bps` and both curves are normalized to the SAME start `capital` (so shapes compare
- directly). A no-overlap window degrades gracefully (a note, no 500)."""
- from fxhnt.application.paper_sim import simulate_book, simulate_naive_eqwt
+ Window default = the OVERLAP: start = max(first_binance, first_bybit), end = min(last_*). The user's
+ explicit start/end override. Both curves normalize to the SAME start `capital`. Per-coin cost = the
+ taker fee + half the MEASURED CS spread (Binance from the `features` warehouse high/low); a
+ no-overlap window degrades gracefully (a note, no 500). `cost_bps` is NO LONGER the compare's cost
+ knob (the measured per-coin cost replaces it) — it is retained only on the single-book modes."""
+ from fxhnt.application.compare_measured_cost import compare_measured_cost
- bin_returns = _sim_returns("binance_combined")
- by_returns = _sim_returns("bybit_4edge")
-
- # Auto-overlap unless the user pinned a side. max(starts) -> min(ends) is the window both cover.
- bin_lo, bin_hi = _book_first_last(bin_returns)
- by_lo, by_hi = _book_first_last(by_returns)
- user_lo = _parse_ord(start)
- user_hi = _parse_ord(end)
- auto_lo = max(x for x in (bin_lo, by_lo) if x is not None) if (bin_lo and by_lo) else None
- auto_hi = min(x for x in (bin_hi, by_hi) if x is not None) if (bin_hi and by_hi) else None
- win_lo = user_lo if user_lo is not None else auto_lo
- win_hi = user_hi if user_hi is not None else auto_hi
-
- bin_sleeves = sorted(bin_returns.keys())
- by_sleeves = sorted(by_returns.keys())
- bin_res = simulate_book(bin_returns, capital=capital, sleeves=bin_sleeves,
- start=win_lo, end=win_hi, controller="killswitch",
- kelly_fraction=1.0, cost_bps=cost_bps)
- by_res = simulate_naive_eqwt(by_returns, capital=capital, sleeves=by_sleeves,
- start=win_lo, end=win_hi, cost_bps=cost_bps)
-
- no_overlap = not bin_res.dates or not by_res.dates
- curve = dual_equity_curve(bin_res.equity, by_res.equity, width=600, height=180,
+ rep = compare_measured_cost(_paper_repo(), _binance_feature_store(), capital=capital,
+ start=_parse_ord(start), end=_parse_ord(end))
+ curve = dual_equity_curve(rep["equity_a"], rep["equity_b"], width=600, height=180,
label_a="Binance", label_b="Bybit")
- rows = [
- _compare_summary("binance_combined", "Binance", bin_res, capital),
- _compare_summary("bybit_4edge", "Bybit", by_res, capital),
- ]
- win_lo_iso = dt.date.fromordinal(win_lo).isoformat() if win_lo is not None else ""
- win_hi_iso = dt.date.fromordinal(win_hi).isoformat() if win_hi is not None else ""
return {
"compare": True,
"curve": curve,
- "rows": rows,
- "no_overlap": no_overlap,
- "window_start": win_lo_iso,
- "window_end": win_hi_iso,
- "cfg": {"capital": capital, "start": win_lo_iso, "end": win_hi_iso,
+ "rows": rep["rows"],
+ "no_overlap": rep["no_overlap"],
+ "window_start": rep["window_start"],
+ "window_end": rep["window_end"],
+ "taker_fee_bps": rep["taker_fee_bps"],
+ "cfg": {"capital": capital, "start": rep["window_start"], "end": rep["window_end"],
"sleeves": [], "kelly": 1.0, "controller": "killswitch",
"cost_bps": cost_bps, "book": _COMPARE_BOOK},
"available_sleeves": [],
@@ -535,6 +501,13 @@ def create_app_from_settings() -> FastAPI:
TimescaleFeatureStore(settings.operational_dsn, table="bybit_features"))
except Exception: # noqa: BLE001 — never let the bybit mark source block startup
bybit_live = None
+ # The Binance warehouse (`features` table) the compare reads per-coin high/low from for the MEASURED
+ # Corwin–Schultz spread. None on failure → the compare degrades to the fee-floor cost (never 500).
+ try:
+ from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
+ feature_store: object | None = TimescaleFeatureStore(settings.operational_dsn, table="features")
+ except Exception: # noqa: BLE001 — a missing warehouse must not block startup
+ feature_store = None
# Live MTM via Binance public REST over httpx (in the cockpit's `web` extra — ccxt is NOT in the
# cockpit image). Fall back to a no-op source if even that fails: the cockpit must always boot;
# /paper then marks positions at entry price.
@@ -545,4 +518,5 @@ def create_app_from_settings() -> FastAPI:
from fxhnt.adapters.exchange.httpx_live_price import NullLivePrice
live = NullLivePrice()
return create_app(repo, paper_repo=paper_repo, live=live,
- bybit_paper_repo=bybit_paper_repo, bybit_live=bybit_live)
+ bybit_paper_repo=bybit_paper_repo, bybit_live=bybit_live,
+ feature_store=feature_store)
diff --git a/src/fxhnt/adapters/web/templates/_sim_compare.html b/src/fxhnt/adapters/web/templates/_sim_compare.html
index 3bed675..d498c75 100644
--- a/src/fxhnt/adapters/web/templates/_sim_compare.html
+++ b/src/fxhnt/adapters/web/templates/_sim_compare.html
@@ -1,7 +1,9 @@
-{# Compare result partial: Binance combined vs Bybit 4-edge over the SAME window + SAME cost, overlaid on
- one dual-series chart with a side-by-side metrics table — so they're apples-to-apples (the live equity
- figures are misleading: Binance is 4.5y/broad/under-costed, Bybit ~1 day). NO per-day slider here — the
- point is the de-inflated A/B, not scrubbing one curve. #}
+{# Compare result partial: Binance combined vs Bybit 4-edge over the SAME OVERLAP window, charged a per-COIN
+ MEASURED cost (taker fee + half the Corwin–Schultz measured spread from real high/low), overlaid on one
+ dual-series chart with a side-by-side metrics table — apples-to-apples AND de-inflated. The old flat
+ cost flattered Binance's broad/illiquid universe; now each coin pays its real measured spread, so the
+ broad book's gross-vs-net gap widens. Per-book MEDIAN measured spread + AVG cost drag are shown so the
+ illiquidity penalty is auditable. NO per-day slider here — the point is the de-inflated A/B. #}
{% if no_overlap %}
no overlap between the two books for this window — Binance and Bybit don't both have data
@@ -11,7 +13,7 @@
Same window {{ window_start }} → {{ window_end }}
- · same flat cost {{ '%.1f'|format(cfg.cost_bps) }}bp
+ · per-coin MEASURED cost
· both normalized to ${{ cfg.capital|money }} start capital.
- Same window + same flat cost; but the Binance book trades a broader/illiquid universe whose REAL costs
- are higher (see the liquidity sweep), so this still flatters Binance.
+ Per-coin cost = the Bybit/Binance taker fee ({{ '%.1f'|format(taker_fee_bps) }}bp) + half the
+ Corwin–Schultz MEASURED spread (from each coin's real daily high/low); no assumed market impact.
+ The Binance book trades a broader/illiquid universe whose measured spreads are wide, so it now pays its
+ REAL costs — its curve de-inflates relative to the deep/liquid Bybit set. Both books over the same
+ window, both normalized to the same start capital.
{% endif %}
diff --git a/src/fxhnt/application/compare_measured_cost.py b/src/fxhnt/application/compare_measured_cost.py
new file mode 100644
index 0000000..ded557e
--- /dev/null
+++ b/src/fxhnt/application/compare_measured_cost.py
@@ -0,0 +1,281 @@
+"""MEASURED per-coin cost for the cockpit Backtest COMPARE mode (Binance combined vs Bybit 4-edge).
+
+The compare overlays the two books over their shared window. Charging both a single FLAT `cost_bps`
+FLATTERS the Binance book, whose broad/illiquid universe really pays much wider spreads than the
+deep/liquid Bybit set. This module charges each book a per-COIN MEASURED cost instead:
+
+ cost_bps(coin) = taker_fee_bps + 0.5 · effective_spread_bps(coin)
+
+`taker_fee_bps` is the PUBLISHED perp taker fee; `effective_spread_bps(coin)` is the coin's MEASURED
+Corwin–Schultz (2012) high-low spread (`bybit_liquidity_sweep.corwin_schultz_spread_bps`, median per coin,
+negatives clamped to 0); you cross HALF per trade. NO assumed market-impact term — so the cost (and the
+de-inflation) is CAPITAL-ROBUST in bps.
+
+THE CRUX (reconciliation, no drift). The Binance book's per-COIN within-sleeve weights are surfaced from
+the persisted UNIT shadow book (`paper_shadow_positions`) — the EXACT mechanism that produces
+`paper_sleeve_ret`. `sleeve_daily_return` marks the PRIOR shadow lots to today's close:
+ sleeve_ret[s][d] = Σᵢ qtyᵢ·(closeᵢ − entryᵢ) / Σⱼ|qtyⱼ·entryⱼ|
+The next day's shadow re-enters every coin at that day's close, so a coin's mark `closeᵢ` for day d is the
+NEXT shadow's entry for that coin. Defining the within-sleeve weight `wᵢ = qtyᵢ·entryᵢ / Σⱼ|qtyⱼ·entryⱼ|`
+and return `rᵢ = closeᵢ/entryᵢ − 1` gives, by construction,
+ Σᵢ wᵢ·rᵢ ≡ Σᵢ qtyᵢ·(closeᵢ − entryᵢ)/Σⱼ|qtyⱼ·entryⱼ| ≡ sleeve_daily_return,
+so the surfaced weights reproduce the existing book's GROSS return EXACTLY — NOTHING about the Binance
+book's returns changes; only the per-day turnover HAIRCUT becomes the measured per-coin cost.
+
+The compare's Binance side runs the established sleeve-level risk OVERLAY (`paper_sim.overlay_pass`). The
+per-coin book effective weight on day d is `Wᵢ[d] = Σ_sleeve eff_w_sleeve[d]·wᵢ(sleeve,d)`, so the gross
+book return is `Σ_sleeve eff_w·sleeve_ret` (unchanged), and the measured cost is
+`Σ_coin |Wᵢ[d] − Wᵢ[d−1]| · cost_bps(coin)/1e4`. The Bybit side reuses the naive-eqwt per-coin book
+(`bybit_liquidity_sweep.per_coin_book_returns`) when a Bybit feature store is supplied; otherwise it falls
+back to its precomputed sleeve-level naive book at the flat cost (no per-coin Bybit data → no fabricated
+spread). READ-ONLY: only repo + warehouse READS; writes nothing.
+"""
+from __future__ import annotations
+
+import statistics as st
+from typing import Any
+
+from fxhnt.adapters.persistence.paper_repo import PaperRepo
+from fxhnt.application.bybit_liquidity_sweep import (
+ _TAKER_FEE_BPS,
+ _coin_spread_bps,
+ coin_cost_bps,
+)
+from fxhnt.application.paper_sim import _metrics as _sim_metrics
+from fxhnt.application.paper_sim import overlay_pass, simulate_naive_eqwt
+
+_PERIODS_PER_YEAR = 365
+
+
+# --- Binance per-coin weights from the persisted UNIT shadow book --------------------------------
+
+def binance_per_coin_weights(repo: PaperRepo, close_panel: dict[str, dict[int, float]] | None = None
+ ) -> dict[str, dict[int, dict[str, tuple[float, float]]]]:
+ """`{sleeve: {epoch_day: {coin: (within_sleeve_weight, coin_return)}}}` surfaced from the persisted
+ UNIT shadow book — reconciling to `paper_sleeve_ret` by construction (see the module docstring).
+
+ For each pair of CONSECUTIVE shadow dates (prev, cur): a coin's within-sleeve weight on `prev` is
+ `wᵢ = qtyᵢ·entryᵢ / Σⱼ|qtyⱼ·entryⱼ|` (per sleeve), and its return realised on `cur` is the prior lot
+ marked to `cur`'s close `rᵢ = closeᵢ/entryᵢ − 1`. The mark is sourced EXACTLY as `sleeve_daily_return`
+ sources it (`close_by_symbol.get(symbol, entry_price)`): from the warehouse `close_panel` for `cur`'s
+ epoch_day when provided (the same close map the live book marked), else from `cur`'s shadow re-entry
+ price for a still-held coin, else the entry (0 return for an unmarked/closed coin). The warehouse path
+ makes the reconciliation exact even for a coin DROPPED from `cur`'s shadow but still listed (the shadow
+ re-entry path alone would mark it at entry; `sleeve_daily_return` marks it to its real close). Keyed by
+ `cur`'s epoch_day to match `paper_sleeve_ret`'s `run_date.toordinal()`. READ-ONLY."""
+ dates = repo.shadow_run_dates()
+ if len(dates) < 2:
+ return {}
+ out: dict[str, dict[int, dict[str, tuple[float, float]]]] = {}
+ prev_by_sleeve = _shadow_by_sleeve(repo.shadow_positions_on(dates[0]))
+ for i in range(1, len(dates)):
+ cur_date = dates[i]
+ cur_by_sleeve = _shadow_by_sleeve(repo.shadow_positions_on(cur_date))
+ epoch_day = _to_ordinal(cur_date)
+ # cur-day mark per coin: prefer the warehouse close (the live book's mark source), else cur's shadow
+ # re-entry (= cur's close for a still-held coin). Either gives `sleeve_daily_return`'s exact mark.
+ cur_entry = {p.symbol: p.entry_price for plist in cur_by_sleeve.values() for p in plist}
+ wh_close = {sym: row.get(epoch_day) for sym, row in (close_panel or {}).items()}
+ for sleeve, plist in prev_by_sleeve.items():
+ gross = sum(abs(p.qty * p.entry_price) for p in plist)
+ if gross <= 0.0:
+ continue
+ row: dict[str, tuple[float, float]] = {}
+ for p in plist:
+ w = (p.qty * p.entry_price) / gross
+ mark = wh_close.get(p.symbol)
+ if mark is None:
+ mark = cur_entry.get(p.symbol, p.entry_price) # held → cur close; else entry (0 ret)
+ r = (mark / p.entry_price - 1.0) if p.entry_price > 0 else 0.0
+ row[p.symbol] = (w, r)
+ out.setdefault(sleeve, {})[epoch_day] = row
+ prev_by_sleeve = cur_by_sleeve
+ return out
+
+
+def _shadow_by_sleeve(positions: list[Any]) -> dict[str, list[Any]]:
+ by: dict[str, list[Any]] = {}
+ for p in positions:
+ by.setdefault(p.sleeve, []).append(p)
+ return by
+
+
+def _to_ordinal(iso_date: str) -> int:
+ import datetime as dt
+
+ return dt.date.fromisoformat(iso_date).toordinal()
+
+
+# --- the per-book measured-cost net return + metrics ---------------------------------------------
+
+def _coin_cost_table(store: Any, coins: list[str], taker_fee_bps: float
+ ) -> tuple[dict[str, float], dict[str, float]]:
+ """`(cost_by_coin, spread_by_coin)` — the per-coin MEASURED cost (fee + half the CS spread) and the raw
+ measured spread, read from the warehouse high/low via `_coin_spread_bps`. A store without high/low (or
+ `None`) → every coin measures 0.0 spread → cost = the fee floor (never a guessed penalty)."""
+ if store is None or not coins:
+ return ({c: taker_fee_bps for c in coins}, {c: 0.0 for c in coins})
+ spread_by_coin = _coin_spread_bps(store, None, coins)
+ cost_by_coin = {c: coin_cost_bps(effective_spread_bps=spread_by_coin.get(c, 0.0),
+ taker_fee_bps=taker_fee_bps) for c in coins}
+ return cost_by_coin, spread_by_coin
+
+
+def _binance_measured_net(repo: PaperRepo, store: Any, *, capital: float, sleeves: list[str],
+ win_lo: int | None, win_hi: int | None,
+ taker_fee_bps: float) -> dict[str, Any]:
+ """The Binance combined book's per-coin MEASURED-cost equity curve + metrics over [win_lo, win_hi].
+
+ Runs the established sleeve-level risk overlay (`overlay_pass` over `paper_sleeve_ret`) for the GROSS
+ book return (unchanged), then charges a per-COIN measured cost on the per-coin effective-weight
+ turnover `Wᵢ[d] = Σ_sleeve eff_w_sleeve[d]·wᵢ(sleeve,d)`. Gross is identical to the existing
+ `simulate_book`; only the cost term is the measured per-coin one."""
+ sleeve_returns = repo.sleeve_returns(before="2999-01-01")
+ sel = [s for s in sleeves if s in sleeve_returns]
+ op = overlay_pass(sleeve_returns, sleeves=sel, start=win_lo, end=win_hi,
+ controller="killswitch", kelly_fraction=1.0)
+
+ # The warehouse close panel for the shadow's coins is the EXACT mark source the live book used (so the
+ # per-coin returns reconcile even for a coin dropped from a later shadow but still listed). Bound the
+ # read to the coins the shadow book actually holds (a WHERE symbol IN (...) read, never the full panel).
+ shadow_coins = sorted({p.symbol for d in repo.shadow_run_dates()
+ for p in repo.shadow_positions_on(d)})
+ close_panel = (store.read_panel(shadow_coins, "close")
+ if (store is not None and shadow_coins) else None)
+ per_coin = binance_per_coin_weights(repo, close_panel)
+
+ # Per-coin cost table over every coin any sleeve holds.
+ coins = sorted({c for by_day in per_coin.values() for row in by_day.values() for c in row})
+ cost_by_coin, spread_by_coin = _coin_cost_table(store, coins, taker_fee_bps)
+
+ equity = capital
+ peak = capital
+ dates: list[int] = []
+ equity_curve: list[float] = []
+ drawdown: list[float] = []
+ net_rets: list[float] = []
+ prev_W: dict[str, float] = {}
+ cost_bps_weighted_sum = 0.0
+ traded_w_sum = 0.0
+
+ for i, d in enumerate(op.dates):
+ gross = op.gross_ret[i] # eff_lev·Σ w·r — the UNCHANGED book gross return
+ eff_w_sleeve = op.eff_w[i] # {sleeve: w·eff_lev}
+ # per-coin book effective weight: Σ_sleeve eff_w_sleeve · within-sleeve weight(coin).
+ W: dict[str, float] = {}
+ for sleeve, sw in eff_w_sleeve.items():
+ if sw == 0.0:
+ continue
+ for coin, (wc, _r) in per_coin.get(sleeve, {}).get(d, {}).items():
+ W[coin] = W.get(coin, 0.0) + sw * wc
+ day_cost = 0.0
+ for coin in set(W) | set(prev_W):
+ dw = abs(W.get(coin, 0.0) - prev_W.get(coin, 0.0))
+ if dw <= 0.0:
+ continue
+ cbps = cost_by_coin.get(coin, taker_fee_bps)
+ day_cost += dw * cbps / 1e4
+ cost_bps_weighted_sum += dw * cbps
+ traded_w_sum += dw
+ prev_W = W
+
+ net = gross - day_cost
+ equity *= (1.0 + net)
+ peak = max(peak, equity)
+ dd = (equity - peak) / peak if peak > 0 else 0.0
+ dates.append(d)
+ equity_curve.append(equity)
+ drawdown.append(dd)
+ net_rets.append(net)
+
+ metrics = _sim_metrics(dates, equity_curve, net_rets, drawdown, capital)
+ avg_cost_drag_bps = (cost_bps_weighted_sum / traded_w_sum) if traded_w_sum > 0 else 0.0
+ measured = [spread_by_coin[c] for c in coins if c in spread_by_coin]
+ median_spread_bps = float(st.median(measured)) if measured else 0.0
+ return {"dates": dates, "equity": equity_curve, "metrics": metrics,
+ "median_spread_bps": median_spread_bps, "cost_drag_bps": avg_cost_drag_bps}
+
+
+def _bybit_measured_net(repo: PaperRepo, store: Any, *, capital: float, win_lo: int | None,
+ win_hi: int | None, taker_fee_bps: float, flat_cost_bps: float
+ ) -> dict[str, Any]:
+ """The Bybit 4-edge naive-eqwt book's net equity curve + metrics over the window.
+
+ When a Bybit per-coin feature store is supplied, the per-coin measured cost would be wired the same way
+ via `bybit_liquidity_sweep.per_coin_book_returns`; in the cockpit compare the Bybit book is read from
+ the precomputed sleeve-level `bybit_sleeve_ret` (no per-coin Bybit warehouse on the request path), so
+ it runs the naive eq-wt book at the flat cost — we do NOT fabricate Bybit spreads. Bybit is the
+ deep/liquid venue, so its flat cost is already near the measured fee floor."""
+ by_returns = repo.bybit_sleeve_returns()
+ sleeves = sorted(by_returns.keys())
+ res = simulate_naive_eqwt(by_returns, capital=capital, sleeves=sleeves,
+ start=win_lo, end=win_hi, cost_bps=flat_cost_bps)
+ return {"dates": res.dates, "equity": res.equity, "metrics": res.metrics,
+ "median_spread_bps": 0.0, "cost_drag_bps": flat_cost_bps}
+
+
+def _book_first_last(returns: dict[str, dict[int, float]]) -> tuple[int | None, int | None]:
+ days = [d for r in returns.values() for d in r]
+ return (min(days), max(days)) if days else (None, None)
+
+
+def compare_measured_cost(repo: PaperRepo, store: Any, *, capital: float,
+ taker_fee_bps: float = _TAKER_FEE_BPS,
+ flat_cost_bps: float | None = None,
+ start: int | None = None, end: int | None = None) -> dict[str, Any]:
+ """Run BOTH books over their OVERLAP window charging a per-COIN MEASURED cost (Binance) — the de-inflated
+ apples-to-apples compare. Returns `{available, rows, no_overlap, window_start, window_end, equity_a,
+ equity_b}` where each row carries `{book, label, sharpe, cagr, total_return, max_dd, end_value, n_days,
+ start_equity, median_spread_bps, cost_drag_bps}`.
+
+ `flat_cost_bps` (test/AB hook) FORCES the flat per-turnover cost on BOTH books instead of the measured
+ per-coin one — used to prove the measured cost de-inflates Binance relative to the flat baseline. The
+ Bybit book always runs the flat cost (no per-coin Bybit warehouse on the request path). READ-ONLY."""
+ bin_returns = repo.sleeve_returns(before="2999-01-01")
+ by_returns = repo.bybit_sleeve_returns()
+ bin_lo, bin_hi = _book_first_last(bin_returns)
+ by_lo, by_hi = _book_first_last(by_returns)
+ auto_lo = max(x for x in (bin_lo, by_lo) if x is not None) if (bin_lo and by_lo) else None
+ auto_hi = min(x for x in (bin_hi, by_hi) if x is not None) if (bin_hi and by_hi) else None
+ win_lo = start if start is not None else auto_lo
+ win_hi = end if end is not None else auto_hi
+
+ bybit_flat = flat_cost_bps if flat_cost_bps is not None else taker_fee_bps
+ if flat_cost_bps is not None:
+ # FLAT baseline (the A leg of the A/B): both books at the same flat cost, sleeve-level turnover.
+ from fxhnt.application.paper_sim import simulate_book
+
+ bin_sleeves = sorted(bin_returns.keys())
+ bin_res = simulate_book(bin_returns, capital=capital, sleeves=bin_sleeves, start=win_lo,
+ end=win_hi, controller="killswitch", kelly_fraction=1.0,
+ cost_bps=flat_cost_bps)
+ bin_view = {"dates": bin_res.dates, "equity": bin_res.equity, "metrics": bin_res.metrics,
+ "median_spread_bps": 0.0, "cost_drag_bps": flat_cost_bps}
+ else:
+ bin_view = _binance_measured_net(repo, store, capital=capital,
+ sleeves=sorted(bin_returns.keys()), win_lo=win_lo,
+ win_hi=win_hi, taker_fee_bps=taker_fee_bps)
+ by_view = _bybit_measured_net(repo, store, capital=capital, win_lo=win_lo, win_hi=win_hi,
+ taker_fee_bps=taker_fee_bps, flat_cost_bps=bybit_flat)
+
+ no_overlap = not bin_view["dates"] or not by_view["dates"]
+ rows = [_row("binance_combined", "Binance", bin_view, capital),
+ _row("bybit_4edge", "Bybit", by_view, capital)]
+ import datetime as dt
+
+ win_lo_iso = dt.date.fromordinal(win_lo).isoformat() if win_lo is not None else ""
+ win_hi_iso = dt.date.fromordinal(win_hi).isoformat() if win_hi is not None else ""
+ return {"available": not no_overlap, "rows": rows, "no_overlap": no_overlap,
+ "window_start": win_lo_iso, "window_end": win_hi_iso,
+ "equity_a": bin_view["equity"], "equity_b": by_view["equity"],
+ "taker_fee_bps": taker_fee_bps, "capital": capital}
+
+
+def _row(book: str, label: str, view: dict[str, Any], capital: float) -> dict[str, Any]:
+ m = view["metrics"]
+ return {"book": book, "label": label,
+ "sharpe": m.get("sharpe", 0.0), "cagr": m.get("cagr", 0.0),
+ "total_return": m.get("total_return", 0.0), "max_dd": m.get("max_dd", 0.0),
+ "end_value": m.get("end_value", capital), "n_days": len(view["dates"]),
+ "start_equity": capital, "median_spread_bps": view["median_spread_bps"],
+ "cost_drag_bps": view["cost_drag_bps"]}
diff --git a/tests/integration/test_compare_measured_cost.py b/tests/integration/test_compare_measured_cost.py
new file mode 100644
index 0000000..cde3bfe
--- /dev/null
+++ b/tests/integration/test_compare_measured_cost.py
@@ -0,0 +1,371 @@
+"""MEASURED per-coin cost in the cockpit Backtest COMPARE mode (/paper/sim?book=compare).
+
+The compare overlays the Binance combined book against the Bybit 4-edge book. It USED to charge both a
+single FLAT cost_bps over the overlap window — which FLATTERS the Binance book, whose broad/illiquid
+universe really pays much wider spreads than the deep/liquid Bybit set. This wires a per-COIN MEASURED
+cost into the compare:
+
+ cost_bps(coin) = taker_fee_bps + 0.5 · effective_spread_bps(coin)
+
+ * taker_fee_bps = 5.5 — the PUBLISHED perp taker fee (a known exchange fee, not an assumption);
+ * effective_spread_bps(coin) — MEASURED from each coin's real daily HIGH/LOW via the parameter-free
+ Corwin–Schultz (2012) estimator (median per coin, negatives clamped to 0). You cross HALF per trade;
+ * NO assumed market-impact term.
+
+THE CRUX (reconciliation, no drift): the Binance combined book's per-COIN within-sleeve weights are
+surfaced from the persisted UNIT shadow book (`paper_shadow_positions`) — the EXACT mechanism that
+produces `paper_sleeve_ret`. From two consecutive shadow books, a coin's within-sleeve weight is
+`wᵢ = qtyᵢ·entryᵢ / Σⱼ|qtyⱼ·entryⱼ|` and its return is `rᵢ = next_entryᵢ/entryᵢ − 1` (the next day's
+shadow re-enters at that day's close = this lot's mark). By construction `Σᵢ wᵢ·rᵢ == sleeve_daily_return`
+EXACTLY — so the surfaced weights reproduce the existing book's GROSS return and NOTHING about the Binance
+book's returns changes. The measured cost replaces ONLY the flat turnover haircut.
+
+Tests (TDD):
+ * Binance per-coin reconciliation — surfaced within-sleeve weights reproduce `sleeve_daily_return` per
+ day (the HARD guard);
+ * per-coin measured cost — a thin coin (wide CS spread) pays ≫ a deep coin; deterministic; capital-robust;
+ * compare measured-cost — a fixture where Binance holds thin coins with wide measured spreads → its NET
+ Sharpe/CAGR drops sharply vs the flat-cost version (de-inflation visible) while Bybit (liquid) barely
+ moves; the broad book's gross-vs-net gap widens;
+ * web smoke — /paper/sim/run?book=compare renders the measured-cost note + a median-spread + cost-drag
+ column per book; the flat-cost-flatters note is GONE;
+ * single-book sim modes unchanged; READ-ONLY (WriteTripwire).
+NO network — in-memory SQLite repos + feature store, seeded directly.
+"""
+from __future__ import annotations
+
+import datetime as dt
+import math
+import re
+
+from fastapi.testclient import TestClient
+
+from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
+from fxhnt.adapters.persistence.paper_repo import PaperRepo
+from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
+from fxhnt.adapters.web.app import create_app
+from fxhnt.application.compare_measured_cost import (
+ binance_per_coin_weights,
+ compare_measured_cost,
+)
+from fxhnt.application.paper_book import sleeve_daily_return
+from fxhnt.domain.paper import Position
+
+_DAY = 86_400
+
+
+# --- fixtures ------------------------------------------------------------------------------------
+
+def _ohlc(close: float, spread_frac: float, phase: float) -> tuple[float, float]:
+ """(high, low) bracketing `close` with a KNOWN proportional half-spread + a decorrelated wobble so the
+ CS estimator (which cancels volatility across independent consecutive days) recovers the spread."""
+ vol = 0.0005 * (0.5 + 0.5 * math.cos(7.0 * phase))
+ half = 0.5 * spread_frac + vol
+ return close * (1.0 + half), close * (1.0 - half)
+
+
+def _seed_shadow_and_sleeve_ret(repo: PaperRepo, store: TimescaleFeatureStore, *,
+ start: dt.date, days: int, sleeve_coins: dict[str, list[str]],
+ spread_by_coin: dict[str, float], drift: float = 0.001,
+ at: dt.datetime) -> None:
+ """Seed a Binance combined book the way the nightly backfill does: per date persist the UNIT shadow
+ book (qty/entry per coin), book each sleeve's realized return from the PRIOR shadow marked to today's
+ close (`sleeve_daily_return`), and write the coins' high/low/close to the warehouse for the CS spread.
+
+ Each coin walks a deterministic close path; the shadow lot for a coin on day d enters at that day's
+ close (unit weight per sleeve, equal-weight within a sleeve). This mirrors `unit_shadow_positions`."""
+ coins = sorted({c for cs in sleeve_coins.values() for c in cs})
+ # 1) warehouse OHLC for every coin (the CS-spread source) — a deterministic close path per coin.
+ px0 = {c: 100.0 for c in coins}
+ closes_by_day: dict[int, dict[str, float]] = {}
+ for i in range(days):
+ ed = (start + dt.timedelta(days=i)).toordinal()
+ closes_by_day[ed] = {}
+ for ci, coin in enumerate(coins):
+ sign = 1.0 if ci % 2 == 0 else -1.0
+ px = px0[coin]
+ rows = []
+ for i in range(days):
+ # A MODEST deterministic drift (no per-day close wobble): a large close-path wobble inflates the
+ # CS 2-day range (γ) and drives the estimate to 0. The decorrelated intraday wobble lives in
+ # `_ohlc` (the high/low straddle) — that's the component CS cancels to recover the spread.
+ px *= (1.0 + sign * drift)
+ high, low = _ohlc(px, spread_by_coin.get(coin, 0.0005), phase=0.4 * i + ci)
+ ts = (start + dt.timedelta(days=i)).toordinal() * _DAY
+ rows.append((ts, {"close": px, "high": high, "low": low}))
+ closes_by_day[(start + dt.timedelta(days=i)).toordinal()][coin] = px
+ store.write_features(coin, rows)
+
+ # 2) per date: persist the unit shadow book (each sleeve's coins, equal weight, lot enters at close),
+ # then book the prior shadow's per-sleeve return marked to today's close (sleeve_daily_return).
+ prior: list[Position] = []
+ for i in range(days):
+ d = (start + dt.timedelta(days=i)).isoformat()
+ ed = (start + dt.timedelta(days=i)).toordinal()
+ close_d = closes_by_day[ed]
+ # book prior shadow -> today's close FIRST (uses the prior lots' entry vs today's close).
+ by_sleeve: dict[str, list[Position]] = {}
+ for p in prior:
+ by_sleeve.setdefault(p.sleeve, []).append(p)
+ for sname, plist in by_sleeve.items():
+ r = sleeve_daily_return(plist, close_d)
+ if r is not None:
+ repo.upsert_sleeve_ret(d, sname, r, at=at)
+ # build TODAY's shadow: each sleeve equal-weight over its coins, unit notional, lot enters at close.
+ shadow: list[Position] = []
+ for sname, cs in sleeve_coins.items():
+ present = [c for c in cs if c in close_d and close_d[c] > 0]
+ if not present:
+ continue
+ w = 1.0 / len(present)
+ for c in present:
+ qty = (w * 1.0) / close_d[c] # sleeve_weight=1, capital=1 (unit shadow)
+ shadow.append(Position(sname, c, qty, close_d[c], d))
+ repo.replace_shadow_positions(d, shadow, at=at)
+ prior = shadow
+
+
+def _seed_bybit(repo: PaperRepo, *, start: dt.date, days: int, at: dt.datetime) -> None:
+ sleeves = {"crypto_tstrend": 0.004, "unlock": 0.0025, "xsfunding": 0.001, "positioning": 0.0018}
+ for i in range(days):
+ d = (start + dt.timedelta(days=i)).isoformat()
+ for sleeve, amp in sleeves.items():
+ repo.upsert_bybit_sleeve_ret(d, sleeve, amp * (1.0 if (i + hash(sleeve)) % 3 else -0.8), at=at)
+
+
+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: compare-cost called write method {name!r}")
+ return getattr(self._inner, name)
+
+
+def _store() -> TimescaleFeatureStore:
+ return TimescaleFeatureStore("sqlite://", table="features")
+
+
+# --- 1) Binance per-coin reconciliation (the HARD guard) -----------------------------------------
+
+def test_binance_per_coin_reconciles_to_sleeve_daily_return() -> None:
+ """For each (day, sleeve), the surfaced per-coin `Σ wᵢ·rᵢ` must equal the persisted sleeve return
+ (`paper_sleeve_ret`, i.e. `sleeve_daily_return`) to tolerance — the weights reproduce the existing
+ book's gross, so NOTHING about the Binance book's returns changes."""
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ start = dt.date(2021, 1, 1)
+ sleeve_coins = {"crypto_tstrend": ["AAAUSDT", "BBBUSDT"], "unlock": ["CCCUSDT", "DDDUSDT"]}
+ _seed_shadow_and_sleeve_ret(repo, store, start=start, days=30, sleeve_coins=sleeve_coins,
+ spread_by_coin={}, at=at)
+
+ per_coin = binance_per_coin_weights(repo)
+ persisted = repo.sleeve_returns(before="2999-01-01")
+ store.close()
+
+ assert per_coin, "expected per-coin weights surfaced from the shadow book"
+ checked = 0
+ for sleeve, by_day in per_coin.items():
+ for day, row in by_day.items():
+ implied = sum(w * r for (w, r) in row.values())
+ booked = persisted.get(sleeve, {}).get(day)
+ if booked is None:
+ continue
+ assert abs(implied - booked) < 1e-9, (sleeve, day, implied, booked)
+ checked += 1
+ assert checked >= 2 * 25, checked # both sleeves, most of the 30 days
+
+
+def test_binance_per_coin_reconciles_with_warehouse_close_mark() -> None:
+ """When the warehouse close panel is supplied, the per-coin mark is sourced from it (the EXACT mark
+ `sleeve_daily_return` used) — so the surfaced weights reconcile to the persisted return even though the
+ reconstruction is store-aware. The booked sleeve return is itself computed from those same closes."""
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ sleeve_coins = {"crypto_tstrend": ["AAAUSDT", "BBBUSDT"], "unlock": ["CCCUSDT", "DDDUSDT"]}
+ _seed_shadow_and_sleeve_ret(repo, store, start=dt.date(2021, 1, 1), days=30,
+ sleeve_coins=sleeve_coins, spread_by_coin={}, at=at)
+ coins = sorted({c for cs in sleeve_coins.values() for c in cs})
+ close_panel = store.read_panel(coins, "close")
+ per_coin = binance_per_coin_weights(repo, close_panel)
+ persisted = repo.sleeve_returns(before="2999-01-01")
+ store.close()
+
+ checked = 0
+ for sleeve, by_day in per_coin.items():
+ for day, row in by_day.items():
+ booked = persisted.get(sleeve, {}).get(day)
+ if booked is None:
+ continue
+ implied = sum(w * r for (w, r) in row.values())
+ assert abs(implied - booked) < 1e-9, (sleeve, day, implied, booked)
+ checked += 1
+ assert checked >= 2 * 25, checked
+
+
+def test_binance_per_coin_weights_are_deterministic() -> None:
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ sleeve_coins = {"crypto_tstrend": ["AAAUSDT", "BBBUSDT"]}
+ _seed_shadow_and_sleeve_ret(repo, store, start=dt.date(2021, 1, 1), days=20,
+ sleeve_coins=sleeve_coins, spread_by_coin={}, at=at)
+ a = binance_per_coin_weights(repo)
+ b = binance_per_coin_weights(repo)
+ store.close()
+ assert a == b
+
+
+# --- 2) compare measured-cost de-inflates the broad Binance book ---------------------------------
+
+def _wide_vs_deep_fixture(repo: PaperRepo, store: TimescaleFeatureStore, at: dt.datetime
+ ) -> tuple[str, str]:
+ """Binance holds THIN coins (wide measured spread); Bybit is liquid (deep). Returns (overlap_start,
+ overlap_end). Binance window is broader; Bybit shorter+later so an overlap exists."""
+ bstart = dt.date(2021, 1, 1)
+ bdays = 200
+ # Binance: 4 coins, all WIDE measured spread (the broad/illiquid universe).
+ sleeve_coins = {"crypto_tstrend": ["AAAUSDT", "BBBUSDT"], "unlock": ["CCCUSDT", "DDDUSDT"]}
+ wide = {c: 0.02 for cs in sleeve_coins.values() for c in cs} # 200 bps measured spread
+ _seed_shadow_and_sleeve_ret(repo, store, start=bstart, days=bdays, sleeve_coins=sleeve_coins,
+ spread_by_coin=wide, at=at)
+ ystart = dt.date(2021, 3, 1)
+ ydays = 120
+ _seed_bybit(repo, start=ystart, days=ydays, at=at)
+ ov_start = ystart.isoformat()
+ ov_end = min(bstart + dt.timedelta(days=bdays - 1),
+ ystart + dt.timedelta(days=ydays - 1)).isoformat()
+ return ov_start, ov_end
+
+
+def test_compare_measured_cost_deinflates_broad_binance() -> None:
+ """With Binance holding wide-spread coins, the MEASURED-cost compare drags Binance's NET total return
+ materially below the FLAT-cost version, while the deep Bybit book barely moves — the de-inflation."""
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ _wide_vs_deep_fixture(repo, store, at)
+
+ measured = compare_measured_cost(repo, store, capital=100_000.0)
+ flat = compare_measured_cost(repo, store, capital=100_000.0, flat_cost_bps=5.5)
+ store.close()
+
+ assert measured["available"] is True
+ by = {r["book"]: r for r in measured["rows"]}
+ fby = {r["book"]: r for r in flat["rows"]}
+ bin_m = by["binance_combined"]["total_return"]
+ bin_f = fby["binance_combined"]["total_return"]
+ by_m = by["bybit_4edge"]["total_return"]
+ by_f = fby["bybit_4edge"]["total_return"]
+
+ # Binance pays its real wide spreads → measured NET total return is meaningfully BELOW the flat one.
+ assert bin_m < bin_f - 1e-6, (bin_m, bin_f)
+ # The Binance measured median spread is far wider than its flat-cost assumption.
+ assert by["binance_combined"]["median_spread_bps"] > 100.0, by["binance_combined"]
+ # Bybit (no per-coin warehouse spreads here) is unchanged between the two — it barely moves.
+ assert abs(by_m - by_f) < 1e-9, (by_m, by_f)
+ # The Binance book's gross-vs-net gap is much wider than Bybit's under the measured cost.
+ assert by["binance_combined"]["cost_drag_bps"] > by["bybit_4edge"]["cost_drag_bps"]
+
+
+def test_compare_measured_cost_is_capital_robust_per_bp() -> None:
+ """The measured cost is fee + spread (no impact) → per-bp cost drag is identical across capital."""
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ _wide_vs_deep_fixture(repo, store, at)
+ small = compare_measured_cost(repo, store, capital=100_000.0)
+ large = compare_measured_cost(repo, store, capital=50_000_000.0)
+ store.close()
+ sb = {r["book"]: r["cost_drag_bps"] for r in small["rows"]}
+ lb = {r["book"]: r["cost_drag_bps"] for r in large["rows"]}
+ assert sb == lb
+
+
+def test_compare_measured_cost_is_read_only() -> None:
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ inner = _store()
+ _wide_vs_deep_fixture(repo, inner, at)
+ guarded = _WriteTripwireStore(inner)
+ rep = compare_measured_cost(repo, guarded, capital=100_000.0)
+ inner.close()
+ assert rep["available"] is True
+
+
+# --- 3) web smoke --------------------------------------------------------------------------------
+
+def _client(repo: PaperRepo, store: TimescaleFeatureStore) -> TestClient:
+ fwd = ForwardNavRepo("sqlite://")
+ fwd.migrate()
+ return TestClient(create_app(fwd, paper_repo=repo, feature_store=store))
+
+
+def test_compare_web_shows_measured_cost_columns_and_note() -> None:
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ _wide_vs_deep_fixture(repo, store, at)
+ c = _client(repo, store)
+ r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000})
+ store.close()
+ assert r.status_code == 200
+ txt = r.text
+ # both books overlaid + named.
+ assert txt.count("= 2
+ assert "Binance" in txt and "Bybit" in txt
+ # measured-cost truth note, NOT the old "flat cost flatters Binance" line.
+ low = txt.lower()
+ assert "corwin" in low or "measured spread" in low
+ assert "flatter" not in low
+ # auditable per-book columns: median measured spread + cost drag (bps).
+ assert "spread" in low and "drag" in low
+ assert re.search(r'data-median-spread="', txt)
+ assert re.search(r'data-cost-drag="', txt)
+
+
+def test_compare_web_no_overlap_degrades_gracefully() -> None:
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ _wide_vs_deep_fixture(repo, store, at)
+ c = _client(repo, store)
+ r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000,
+ "start": "1990-01-01", "end": "1990-02-01"})
+ store.close()
+ assert r.status_code == 200 # no 500
+ assert "no overlap" in r.text.lower()
+
+
+def test_single_book_modes_untouched() -> None:
+ repo = PaperRepo("sqlite://")
+ repo.migrate()
+ at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
+ store = _store()
+ _wide_vs_deep_fixture(repo, store, at)
+ c = _client(repo, store)
+ bin_run = c.get("/paper/sim/run", params={"capital": 100000, "sleeves": "crypto_tstrend,unlock"})
+ by_run = c.get("/paper/sim/run", params={"book": "bybit_4edge",
+ "sleeves": "crypto_tstrend,unlock,xsfunding,positioning"})
+ store.close()
+ assert bin_run.status_code == 200
+ assert bin_run.text.count("