From 0acf0cccf3cf3881dc838d751f0ea8c2f3c4e42e Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Thu, 25 Jun 2026 21:16:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(cockpit):=20compare=20mode=20charges=20MEA?= =?UTF-8?q?SURED=20per-coin=20costs=20(CS=20spread=20+=20fee)=20=E2=80=94?= =?UTF-8?q?=20de-inflates=20the=20broad=20Binance=20book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/fxhnt/adapters/persistence/paper_repo.py | 19 + src/fxhnt/adapters/web/app.py | 108 ++--- .../adapters/web/templates/_sim_compare.html | 27 +- .../application/compare_measured_cost.py | 281 +++++++++++++ .../integration/test_compare_measured_cost.py | 371 ++++++++++++++++++ tests/integration/test_sim_compare_web.py | 22 +- 6 files changed, 745 insertions(+), 83 deletions(-) create mode 100644 src/fxhnt/application/compare_measured_cost.py create mode 100644 tests/integration/test_compare_measured_cost.py 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.
@@ -26,6 +28,8 @@ total return max DD final $ + median spread (bps) + avg cost drag (bps) n days @@ -33,13 +37,17 @@ {% for row in rows %} + data-start-equity="{{ '%.2f'|format(row.start_equity) }}" + data-median-spread="{{ '%.2f'|format(row.median_spread_bps) }}" + data-cost-drag="{{ '%.2f'|format(row.cost_drag_bps) }}"> {{ row.label }} {{ '%.2f'|format(row.sharpe) }} {{ '%.1f'|format(100*row.cagr) }}% {{ '%.1f'|format(100*row.total_return) }}% {{ '%.1f'|format(100*row.max_dd) }}% ${{ row.end_value|money }} + {{ '%.1f'|format(row.median_spread_bps) }} + {{ '%.1f'|format(row.cost_drag_bps) }} {{ row.n_days }} {% endfor %} @@ -47,8 +55,11 @@

- 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(" None: assert "no overlap" in r.text.lower() or "no simulated" in r.text.lower() -def test_compare_charges_both_books_the_same_cost() -> None: - """The single cost_bps applies to BOTH books — changing it moves both final equities.""" +def test_compare_ignores_the_flat_cost_bps_knob() -> None: + """The compare now charges a per-COIN MEASURED cost (taker fee + half the Corwin–Schultz spread), NOT + the flat `cost_bps`. So changing `cost_bps` must NOT move either book's final equity — the flat knob was + dropped from the compare path (it remains on the single-book sim modes). With no warehouse high/low + seeded here, both books measure the fee floor, so the curves are stable regardless of `cost_bps`.""" c = _client(_seeded_repo()) cheap = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000, "cost_bps": 0}) pricey = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000, "cost_bps": 20}) @@ -128,8 +134,8 @@ def test_compare_charges_both_books_the_same_cost() -> None: cf, pf = _finals(cheap.text), _finals(pricey.text) assert len(cf) == 2 and len(pf) == 2, (cf, pf) # both books reported - # cost moves BOTH books' final equity (same cost charged to each). - assert cf[0] != pf[0] and cf[1] != pf[1] + # the measured per-coin cost replaces the flat knob → cost_bps is inert on the compare path. + assert cf == pf def test_compare_both_normalized_to_same_capital() -> None: