484 lines
26 KiB
Python
484 lines
26 KiB
Python
"""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
|
||
Corwin–Schultz 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 Corwin–Schultz 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
|
||
Corwin–Schultz 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 Corwin–Schultz 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.
|