Files
fxhnt/tests/integration/test_backtest_measured_single_book.py
2026-07-14 00:30:20 +02:00

484 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The cockpit Backtest charges a per-COIN MEASURED cost on the Bybit single book by DEFAULT (/paper/sim).
cost_bps(coin) = taker_fee_bps + 0.5 · effective_spread_bps(coin) (NO assumed market impact)
The heavy per-coin compute runs in the own-memory `fxhnt compare-measured-precompute` Job (it OOM-killed the
4Gi dashboard live); the cockpit ONLY reads the small precomputed per-book artifacts and falls back to the
flat slider when absent (never a 500).
(Phase 0b venue consolidation, Task 7b: the Binance combined-book leg + the Binance-vs-Bybit COMPARE mode
this file used to also test were retired — Bybit is fxhnt's sole crypto venue. The surviving tests below are
unchanged in substance, just Bybit-only.)
Tests (TDD):
* `_bybit_measured_net` — thin-spread fixture → cost drag near the fee floor; wide-spread → much higher;
deterministic;
* precompute persists the single-book measured curves (naive + levered); round-trips (cached == direct
compute on a fixture);
* Backtest measured DEFAULT — bybit_4edge measured = its real costed number; fallback to flat when the
cache is absent (no 500).
NO network — in-memory SQLite repos + feature stores, 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 (
SINGLE_CACHE_KEYS,
_bybit_measured_net,
precompute_single_book_measured,
read_cached_single_measured,
)
_AT = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
_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
CorwinSchultz estimator (which cancels volatility across independent 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_bybit_features(store: TimescaleFeatureStore, *, days: int = 200,
spread_frac: float = 0.0005) -> list[str]:
"""Carry (xsfunding) coins on bybit_features with a KNOWN measured spread. A NEAR-CONSTANT close (px=100)
is used on purpose so the CorwinSchultz estimator (which cancels day-to-day vol) recovers the injected
spread cleanly; the carry sleeve trades off the funding cross-section, which day-to-day creates turnover
so the per-coin measured cost is actually charged. Mirrors the bybit liquidity-sweep carry fixture."""
funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
"XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
for idx, (sym, fund) in enumerate(funding.items()):
rows = []
for d in range(days):
f = fund + 0.0005 * math.cos(0.5 * d + idx)
high, low = _ohlc(100.0, spread_frac, phase=0.5 * d + idx)
rows.append((d * _DAY, {"funding": f, "close": 100.0, "spot_close": 100.0,
"high": high, "low": low, "turnover": 50_000_000.0}))
store.write_features(sym, rows)
return list(funding)
def _seed_bybit_sleeve_ret(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)
def _bybit_store(spread_frac: float) -> TimescaleFeatureStore:
s = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_bybit_features(s, spread_frac=spread_frac)
return s
# --- 1) _bybit_measured_net: thin vs wide measured spread ----------------------------------------
def test_bybit_measured_net_thin_spread_near_fee() -> None:
"""A deep (thin-spread) Bybit universe → the per-coin measured cost drag sits near the published taker
fee floor (fee + half a tiny measured spread)."""
store = _bybit_store(spread_frac=0.0004)
view = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None,
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
store.close()
assert view["dates"], view
assert view["cost_drag_bps"] < 15.0, view["cost_drag_bps"] # ~fee + a few bp
def test_bybit_measured_net_wide_spread_much_higher() -> None:
"""A thin (wide-spread) Bybit universe → the measured cost drag is FAR above the fee floor (half the wide
CorwinSchultz spread dominates), strictly more than the deep universe's."""
deep = _bybit_store(spread_frac=0.0004)
wide = _bybit_store(spread_frac=0.02) # 200 bps measured spread
deep_view = _bybit_measured_net(deep, capital=100_000.0, win_lo=None, win_hi=None,
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
wide_view = _bybit_measured_net(wide, capital=100_000.0, win_lo=None, win_hi=None,
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
deep.close()
wide.close()
assert wide_view["cost_drag_bps"] > 50.0, wide_view["cost_drag_bps"]
assert wide_view["cost_drag_bps"] > deep_view["cost_drag_bps"] * 3, (
wide_view["cost_drag_bps"], deep_view["cost_drag_bps"])
def test_bybit_measured_net_is_deterministic() -> None:
store = _bybit_store(spread_frac=0.01)
a = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None,
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
b = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None,
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
store.close()
assert a["equity"] == b["equity"]
assert a["cost_drag_bps"] == b["cost_drag_bps"]
def test_bybit_measured_dates_render_as_real_years_not_0057() -> None:
"""Regression: `per_coin_book_returns` keys days by UNIX epoch-day (~20627 for 2026), but the cockpit
renders dates via `date.fromordinal` — so `_bybit_measured_net` MUST emit proleptic ORDINALS, else 2026
shows as year 0057 (the ~1969-year Unix-epoch shift). Seed REALISTIC 2026 epoch-days and assert the
rendered years are {2026}; without the conversion they'd be {56, 57}."""
base = (dt.date(2026, 1, 1) - dt.date(1970, 1, 1)).days # Unix epoch-day of 2026-01-01 (~20454)
s = TimescaleFeatureStore("sqlite://", table="bybit_features")
for idx, (sym, fund) in enumerate({"AAAUSDT": 0.001, "XXXUSDT": -0.001}.items()):
rows = []
for k in range(120): # 2026-01-01 .. ~2026-04-30 (all in 2026)
d = base + k
high, low = _ohlc(100.0, 0.01, phase=0.5 * k + idx)
rows.append((d * _DAY, {"funding": fund + 0.0005 * math.cos(0.5 * k + idx), "close": 100.0,
"spot_close": 100.0, "high": high, "low": low, "turnover": 50_000_000.0}))
s.write_features(sym, rows)
view = _bybit_measured_net(s, capital=100_000.0, win_lo=None, win_hi=None,
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
s.close()
assert view["dates"], view
years = {dt.date.fromordinal(d).year for d in view["dates"]} # the render path uses fromordinal
assert years == {2026}, years # NOT {56, 57} — the 0057 bug
# --- 2) precompute persists the single-book measured curves + round-trips ------------------------
def _seeded_all(spread_frac: float = 0.02) -> tuple[PaperRepo, TimescaleFeatureStore]:
repo = PaperRepo("sqlite://")
repo.migrate()
bybit = _bybit_store(spread_frac=spread_frac)
_seed_bybit_sleeve_ret(repo, start=dt.date(2021, 3, 1), days=120, at=_AT)
return repo, bybit
def test_precompute_persists_single_book_curves() -> None:
repo, bybit = _seeded_all()
out = precompute_single_book_measured(repo, bybit, capital=100_000.0, at=_AT, unlock_events=[])
bybit.close()
assert set(out) == {"bybit_4edge", "bybit_4edge_levered"}
payload = read_cached_single_measured(repo, "bybit_4edge")
assert payload is not None
assert payload["book"] == "bybit_4edge"
assert payload["dates_iso"] and payload["equity"]
assert len(payload["equity"]) == len(payload["dates_iso"]) == len(payload["leverage"])
assert SINGLE_CACHE_KEYS["bybit_4edge"] != SINGLE_CACHE_KEYS["bybit_4edge_levered"]
def test_single_book_payload_round_trips_direct_compute() -> None:
"""The cached single-book metrics == computing `_bybit_measured_net` directly on the same fixture (the
precompute is a faithful cache, no drift)."""
repo, bybit = _seeded_all()
by_direct = _bybit_measured_net(bybit, capital=100_000.0, win_lo=None, win_hi=None,
taker_fee_bps=5.5, unlock_events=[])
precompute_single_book_measured(repo, bybit, capital=100_000.0, at=_AT, unlock_events=[])
bybit.close()
by_cached = read_cached_single_measured(repo, "bybit_4edge")
assert by_cached["equity"] == by_direct["equity"]
for k in ("sharpe", "cagr", "total_return", "max_dd", "end_value"):
assert by_cached["metrics"][k] == by_direct["metrics"][k], k
assert by_cached["cost_drag_bps"] == by_direct["cost_drag_bps"]
def test_precompute_single_book_is_idempotent() -> None:
repo, bybit = _seeded_all()
p1 = precompute_single_book_measured(repo, bybit, capital=100_000.0, at=_AT, unlock_events=[])
p2 = precompute_single_book_measured(repo, bybit, capital=100_000.0, at=_AT, unlock_events=[])
bybit.close()
assert p1["bybit_4edge"]["equity"] == p2["bybit_4edge"]["equity"]
assert read_cached_single_measured(repo, "bybit_4edge")["equity"] == p1["bybit_4edge"]["equity"]
def test_precompute_also_writes_the_levered_book_measured_curve() -> None:
"""The precompute ALSO caches the LEVERED shadow book at the SAME measured cost model — a distinct
'bybit_4edge_levered' entry (carry-weighted, gross-capped) whose curve differs from the naive book. This is
how the cockpit Backtest can show the levered curve at the honest measured cost (never flat)."""
from fxhnt.application.bybit_forward_track import _LEVERED_SLEEVE_SHAPE, MAX_BOOK_GROSS
repo, bybit = _seeded_all()
lev_direct = _bybit_measured_net(bybit, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
unlock_events=[], leverage_shape=_LEVERED_SLEEVE_SHAPE,
max_gross=MAX_BOOK_GROSS)
out = precompute_single_book_measured(repo, bybit, capital=100_000.0, at=_AT, unlock_events=[])
bybit.close()
lev_cached = read_cached_single_measured(repo, "bybit_4edge_levered")
assert lev_cached is not None
assert lev_cached["equity"] == lev_direct["equity"]
assert "bybit_4edge_levered" in out
assert lev_cached["equity"] != out["bybit_4edge"]["equity"] # distinct from the naive book
# --- 3) the Backtest measured default / flat what-if / fallback ----------------------------------
def _client(repo: PaperRepo) -> TestClient:
fwd = ForwardNavRepo("sqlite://")
fwd.migrate()
return TestClient(create_app(fwd, paper_repo=repo))
# --- synthetic measured-cache seeder (the honest curve the Backtest reads) ------------------------
# The Backtest page reads ONLY the precomputed per-coin measured curve. For the UI tests (capital-scale,
# period-window, no-controls) we seed a small deterministic payload directly — exactly the shape
# `precompute_single_book_measured` persists — so we don't run the heavy per-coin compute on the request path.
def _seed_measured_cache(repo: PaperRepo, book: str, *, start: dt.date = dt.date(2022, 1, 1),
days: int = 400, capital: float = 100_000.0) -> dict:
"""Persist a synthetic single-book MEASURED-cost payload (the cockpit's honest curve) with a known,
multi-year, drawdown-bearing equity path so window/scale assertions are exact."""
from fxhnt.application.compare_measured_cost import SINGLE_CACHE_KEYS
from fxhnt.application.paper_sim import _metrics
iso, equity, drawdown, net_rets, epoch = [], [], [], [], []
eq, peak = capital, capital
for i in range(days):
d = start + dt.timedelta(days=i)
# A deterministic oscillation that compounds up overall but has a deep local dip mid-series.
r = 0.004 if (i % 5) else -0.05
eq *= (1.0 + r)
peak = max(peak, eq)
iso.append(d.isoformat())
equity.append(eq)
net_rets.append(r)
drawdown.append((eq - peak) / peak if peak > 0 else 0.0)
epoch.append((d - dt.date(1970, 1, 1)).days)
metrics = _metrics(epoch, equity, net_rets, drawdown, capital)
payload = {
"book": book, "dates_iso": iso, "equity": equity, "drawdown": drawdown,
"leverage": [1.0] * days, "killed": [False] * days, "n_active": [4] * days,
"metrics": metrics, "median_spread_bps": 1.0, "cost_drag_bps": 6.5,
"used_real_spread": book == "bybit_4edge", "capital": capital, "taker_fee_bps": 5.5,
}
repo.upsert_compare_measured_cache(SINGLE_CACHE_KEYS[book], payload, at=_AT)
return payload
def _equity(html: str) -> list[float]:
m = re.search(r'"equity":\s*\[([-0-9.,\s]+)\]', html)
assert m, html[:500]
return [float(x) for x in m.group(1).split(",")]
def test_capital_scales_the_measured_curve() -> None:
"""The Backtest reduces to book/capital/period. Capital rescales the cached curve LINEARLY (per-bp cost is
capital-robust): 2x capital -> 2x equity, point for point."""
repo = PaperRepo("sqlite://")
repo.migrate()
_seed_measured_cache(repo, "bybit_4edge", capital=100_000.0)
c = _client(repo)
one = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000})
two = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 200_000})
assert one.status_code == 200 and two.status_code == 200
e1, e2 = _equity(one.text), _equity(two.text)
assert len(e1) == len(e2) and len(e1) > 0
for a, b in zip(e1, e2, strict=True):
assert abs(b - 2.0 * a) < 1.0, (a, b)
def test_period_windows_the_curve_and_recomputes_metrics() -> None:
"""Period start/end WINDOWS the cached curve (fewer points) and the metrics (CAGR/Sharpe/maxDD) are
RECOMPUTED on the window — not the full-history numbers sliced. The full curve has a deep mid-series dip;
a window AFTER the dip must show a shallower max DD than the full history."""
repo = PaperRepo("sqlite://")
repo.migrate()
_seed_measured_cache(repo, "bybit_4edge", start=dt.date(2022, 1, 1), days=400, capital=100_000.0)
c = _client(repo)
full = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000})
win = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000,
"start": "2022-11-01", "end": "2022-12-15"})
assert full.status_code == 200 and win.status_code == 200
n_full, n_win = len(_equity(full.text)), len(_equity(win.text))
assert 0 < n_win < n_full # the window has strictly fewer points
def _maxdd(html: str) -> float:
m = re.search(r'"drawdown":\s*\[([-0-9.,\s eE]+)\]', html)
assert m, html[:500]
return min(float(x) for x in m.group(1).split(","))
# the window resets its drawdown peak at the window start, so its worst DD is shallower than the full
# history's (which includes the deep mid-series dip) — proof the metrics are recomputed on the window.
assert _maxdd(win.text) > _maxdd(full.text)
# and the rendered window dates are bounded by the requested period.
data = _sim_data_dates(win.text)
assert data[0] >= "2022-11-01" and data[-1] <= "2022-12-15"
def test_windowed_backtest_rebases_to_capital_not_the_cumulative_tail() -> None:
"""The bug the user caught: with $350k from a LATE start date, the equity must BEGIN at ~$350k on that date
(a fresh backtest), NOT the tail of the full cumulative curve. The buggy behaviour entered the window at the
2021->date compounded value scaled by capital — e.g. ~$1.6M on $350k, which is impossible. A window REBASES
to `capital`; equity[0] is one day's return from capital, never the multi-year compounded value."""
repo = PaperRepo("sqlite://")
repo.migrate()
seeded = _seed_measured_cache(repo, "bybit_4edge", start=dt.date(2022, 1, 1), days=400, capital=100_000.0)
lo = next(i for i, d in enumerate(seeded["dates_iso"]) if d >= "2022-11-01")
old_rendered = seeded["equity"][lo] * (350_000 / 100_000) # what the OLD buggy code spliced in as eq[0]
c = _client(repo)
win = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 350_000,
"start": "2022-11-01", "end": "2022-12-15"})
assert win.status_code == 200
eq = _equity(win.text)
assert len(eq) > 0
# equity STARTS at ~capital (within one day's return) — a fresh backtest from the start date.
assert abs(eq[0] - 350_000) < 350_000 * 0.06, eq[0]
# and it is CLEARLY NOT the old spliced-in cumulative-tail value (the $1.6M-on-$350k class of bug).
assert abs(eq[0] - old_rendered) > 350_000 * 0.4, (eq[0], old_rendered)
def _sim_data_dates(html: str) -> list[str]:
import json
m = re.search(r'<script type="application/json" id="sim-data">(.*?)</script>', html, re.DOTALL)
assert m
return json.loads(m.group(1))["dates"]
def test_backtest_page_has_no_cost_flat_sleeve_kelly_controller_controls() -> None:
"""The honest Backtest is book/capital/period ONLY. The configurable-cost sandbox is GONE: no cost (bps)
slider, no measured/flat cost-mode select, no sleeve-composition presets/checkboxes, no Kelly slider, no
controller select. Drag-to-0-cost can no longer resurrect the +7097% mirage."""
repo = PaperRepo("sqlite://")
repo.migrate()
_seed_measured_cache(repo, "bybit_4edge")
c = _client(repo)
page = c.get("/paper/sim").text
assert 'name="cost_bps"' not in page and "cost (bps)" not in page # no cost slider
assert 'name="cost_mode"' not in page and "flat (what-if)" not in page # no cost-mode select
assert 'name="sleeve"' not in page and "sim-preset" not in page # no sleeve presets/checkboxes
assert 'name="kelly"' not in page and 'name="controller"' not in page # no Kelly / controller
assert 'type="range"' not in page # no sliders at all
# the honest knobs remain.
assert 'name="capital"' in page and 'name="start"' in page and 'name="end"' in page
# the curve fragment carries the measured marker (the honest real-cost view).
run = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000})
assert 'data-cost-mode="measured"' in run.text
def test_cache_absent_shows_pending_note_not_flat_not_500() -> None:
"""With NO precomputed measured artifact the Backtest degrades to a short 'precomputing' note — NOT a flat
slider, NEVER a 500. (Previously this fell through to the flat what-if; that path is removed.) A stale
book=binance_combined param (the venue Task 7a removed) degrades to the same bybit rendering."""
repo = PaperRepo("sqlite://")
repo.migrate() # nothing seeded
c = _client(repo)
for book in ("bybit_4edge", "binance_combined"):
r = c.get("/paper/sim/run", params={"book": book, "capital": 100_000})
assert r.status_code == 200, (book, r.text[:300])
assert 'data-cost-mode="flat"' not in r.text # NOT the flat what-if
assert "precomputing" in r.text.lower() # the graceful pending note
assert "nightly" in r.text.lower()
def test_bybit_measured_default_shows_real_costed_number() -> None:
"""book=bybit_4edge in MEASURED mode (default) renders the precomputed per-coin measured curve (its real
costed number), with the measured cost-mode indicator."""
repo, bybit = _seeded_all()
precompute_single_book_measured(repo, bybit, capital=100_000.0, at=_AT, unlock_events=[])
bybit.close()
by_cached = read_cached_single_measured(repo, "bybit_4edge")
assert by_cached is not None and by_cached["equity"]
c = _client(repo)
r = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100000})
assert r.status_code == 200
assert 'data-cost-mode="measured"' in r.text
# the rendered curve's final equity == the precomputed measured curve (at the same capital).
m = re.search(r'"equity":\s*\[([-0-9.,\s]+)\]', r.text)
assert m
rendered_final = float(m.group(1).split(",")[-1])
assert abs(rendered_final - by_cached["equity"][-1]) < 1.0, (rendered_final, by_cached["equity"][-1])
# --- 4) REAL L1 quoted-spread override (the core cost-model fix) ----------------------------------
# The Bybit liquid book was costed off a daily-high/low CorwinSchultz proxy that over-states the true quoted
# spread ~104× (1.4bp real vs 143.6bp CS). With the REAL spread the cost drag drops and the net Sharpe rises.
_BYBIT_BOOK_COINS = ["AAAUSDT", "BBBUSDT", "CCCUSDT", "XXXUSDT", "YYYUSDT", "ZZZUSDT"]
def test_bybit_measured_real_spread_lowers_cost_and_lifts_sharpe() -> None:
"""real_spread override (2bp) ≪ the ~200bp CS proxy → lower cost drag AND higher net Sharpe than CS-only
(the core fix). The GROSS book is unchanged — only the per-bp cost model's spread input changes."""
cs_store = _bybit_store(spread_frac=0.02)
real_store = _bybit_store(spread_frac=0.02)
cs = _bybit_measured_net(cs_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
sleeves=["xsfunding"], unlock_events=[])
real = _bybit_measured_net(real_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
sleeves=["xsfunding"], unlock_events=[],
real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
cs_store.close()
real_store.close()
assert cs["used_real_spread"] is False and real["used_real_spread"] is True
assert real["cost_drag_bps"] < cs["cost_drag_bps"], (real["cost_drag_bps"], cs["cost_drag_bps"])
assert real["metrics"]["sharpe"] > cs["metrics"]["sharpe"], (
real["metrics"]["sharpe"], cs["metrics"]["sharpe"])
# GROSS is untouched: the real cost drag still sits at the exact taker_fee + 0.5·real_spread floor.
assert abs(real["cost_drag_bps"] - (5.5 + 0.5 * 2.0)) < 1e-6, real["cost_drag_bps"]
def test_bybit_measured_partial_override_falls_back_to_cs() -> None:
"""A coin with NO stored real spread falls back to its CS estimate: partial (1 real, rest CS) cost drag
sits strictly between all-real (cheap) and all-CS (expensive)."""
full_store = _bybit_store(spread_frac=0.02)
part_store = _bybit_store(spread_frac=0.02)
cs_store = _bybit_store(spread_frac=0.02)
full = _bybit_measured_net(full_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
sleeves=["xsfunding"], unlock_events=[],
real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
part = _bybit_measured_net(part_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
sleeves=["xsfunding"], unlock_events=[],
real_spread_by_coin={_BYBIT_BOOK_COINS[0]: 2.0})
cs = _bybit_measured_net(cs_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
sleeves=["xsfunding"], unlock_events=[])
full_store.close()
part_store.close()
cs_store.close()
assert full["cost_drag_bps"] < part["cost_drag_bps"] < cs["cost_drag_bps"], (
full["cost_drag_bps"], part["cost_drag_bps"], cs["cost_drag_bps"])
def test_precompute_threads_real_spread_for_bybit() -> None:
"""precompute passes the real spread through for bybit_4edge — the cost drag drops and the override flag
is set."""
repo1, bybit1 = _seeded_all(spread_frac=0.02)
base = precompute_single_book_measured(repo1, bybit1, capital=100_000.0, at=_AT, unlock_events=[])
bybit1.close()
repo2, bybit2 = _seeded_all(spread_frac=0.02)
withreal = precompute_single_book_measured(repo2, bybit2, capital=100_000.0, at=_AT,
unlock_events=[],
bybit_real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
bybit2.close()
# bybit cost drag DROPS with the real spread + flags it.
assert withreal["bybit_4edge"]["used_real_spread"] is True
assert base["bybit_4edge"]["used_real_spread"] is False
assert withreal["bybit_4edge"]["cost_drag_bps"] < base["bybit_4edge"]["cost_drag_bps"]
def test_bybit_caption_shows_real_spread_and_keeps_measured_marker() -> None:
"""When the bybit book used real spreads the caption says so (forward-realistic / current-snapshot caveat
NOT hidden) while the existing data-cost-mode="measured" marker stays intact."""
repo, bybit = _seeded_all(spread_frac=0.02)
precompute_single_book_measured(repo, bybit, capital=100_000.0, at=_AT, unlock_events=[],
bybit_real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
bybit.close()
c = _client(repo)
r = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100000})
assert r.status_code == 200
assert 'data-cost-mode="measured"' in r.text # marker preserved
assert "real L1 quoted spread" in r.text
assert "forward-realistic" in r.text and "2021-22 spreads were wider" in r.text
# NB: the old `test_measured_default_falls_back_to_flat_when_cache_absent` (cache-absent => flat slider) is
# replaced by `test_cache_absent_shows_pending_note_not_flat_not_500` above — the flat what-if is no longer a
# user path; an absent measured cache now degrades to a "precomputing" note, never the flat slider.