Files
fxhnt/tests/integration/test_bybit_true_spread.py
jgrusewski 4343cd9e13 feat(research): measure TRUE Bybit L1 spread + re-cost maker grid with it (replaces high/low CS proxy)
Part A: BybitTrueSpread adapter fetches the live L1 quoted spread from the
v5/market/tickers?category=linear endpoint (bid1Price/ask1Price/lastPrice),
spread_bps = (ask-bid)/mid*1e4. Injectable fetch/clock with rate-limit backoff,
defensive parsing (missing/zero/crossed quotes skipped), clock-stamped snapshot.
CLI `fxhnt bybit-true-spread` prints median/mean REAL quoted spread vs the median
Corwin-Schultz high/low estimate for the liquid-61 book + the over-estimate ratio.

Part B: bybit_maker_recost gains a real_spread_by_coin override used in BOTH cost
legs (CS fallback for unquoted coins); maker_recost_sweep builds both the CS grid
and the REAL grid and reports compare_spreads (ratio + fallback count). CLI
`fxhnt maker-recost --real-spread` prints both grids side by side with the
current-snapshot-applied-to-history caveat (forward cost, not 2021-22 cost).

READ-ONLY (WriteTripwire), memory-bounded, no network in tests (HTTP fixtured).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:46:40 +02:00

331 lines
15 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.
"""TRUE Bybit L1 quoted-spread fetcher + re-costing the maker grid with the REAL spread.
The maker-recost grid's load-bearing input is a 143.6bp median per-coin spread estimated by CorwinSchultz
from DAILY HIGH/LOW — which captures intraday RANGE, not the quoted bid/ask, so it MASSIVELY over-states the
true L1 spread on liquid coins (BTC/ETH quote spreads ≈ 1bp). These tests prove:
Part A (fetcher, ALL HTTP injected — NO network):
* the tickers endpoint rows -> correct (askbid)/mid·1e4 spread bps;
* missing / zero / crossed quotes are SKIPPED (defensive — a malformed row never raises);
* the snapshot is clock-deterministic (stamped from the INJECTED clock, no bare time.time);
* a universe filter restricts the returned symbols.
Part B (re-cost with the REAL spread):
* f=0 + a real_spread override ties out to the EXACT cost formula taker_fee + 0.5·real_spread (the same
blended_cost_bps math, only the spread input changes);
* a real_spread override reproduces the maker grid math but with the injected spreads;
* DIRECTION: a fixture where real_spread ≪ CS → the all-taker baseline Sharpe is HIGHER under REAL AND the
maker lift is SMALLER (tighter real spread → less to save);
* compare_spreads reports the over-estimate ratio + the fallback count.
"""
from __future__ import annotations
import math
from fxhnt.adapters.data.bybit_true_spread import BybitTrueSpread, spread_bps
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_liquidity_sweep import per_coin_book_returns
from fxhnt.application.bybit_maker_recost import (
blended_cost_bps,
compare_spreads,
maker_recost_sweep,
recost_net_series,
true_spread_report,
)
_DAY = 86_400
_NOW = 1780315200.0 # noon 2026-06-01 UTC
def _FIXED_CLOCK() -> float: # noqa: N802 — constant-like injectable clock
return _NOW
# --- a tickers payload -------------------------------------------------------------------------------
def _ticker(symbol: str, bid: str | float | None, ask: str | float | None,
last: str | float = "100") -> dict:
return {"symbol": symbol, "bid1Price": bid, "ask1Price": ask, "lastPrice": last}
def _tickers_payload(rows: list[dict]) -> dict:
return {"retCode": 0, "result": {"list": rows}}
# --- Part A: the pure spread_bps helper --------------------------------------------------------------
def test_spread_bps_basic() -> None:
# bid 99.99, ask 100.01 → spread 0.02 over mid 100.0 → 2.0 bps.
assert abs(spread_bps("99.99", "100.01") - 2.0) < 1e-9
def test_spread_bps_skips_missing_and_zero() -> None:
assert spread_bps(None, "100.0") is None
assert spread_bps("0", "100.0") is None
assert spread_bps("100.0", "0") is None
assert spread_bps("abc", "100.0") is None
def test_spread_bps_skips_crossed_book() -> None:
# ask < bid is a crossed/garbage book → skip, do NOT return a negative spread.
assert spread_bps("100.5", "100.0") is None
def test_spread_bps_tight_book_is_zero_not_skipped() -> None:
assert spread_bps("100.0", "100.0") == 0.0
# --- Part A: the fetcher -----------------------------------------------------------------------------
def test_fetcher_parses_spreads_from_fixture() -> None:
payload = _tickers_payload([
_ticker("BTCUSDT", "99999.5", "100000.5"), # ~0.1 bps
_ticker("ETHUSDT", "1999.98", "2000.02"), # ~0.2 bps
])
fetch = lambda _url: payload # noqa: E731
out = BybitTrueSpread(fetch=fetch, sleep=lambda _: None).real_spread_bps()
assert set(out) == {"BTCUSDT", "ETHUSDT"}
assert abs(out["BTCUSDT"] - (1.0 / 100000.0 * 1e4)) < 1e-6
assert abs(out["ETHUSDT"] - (0.04 / 2000.0 * 1e4)) < 1e-6
def test_fetcher_skips_malformed_rows_without_crashing() -> None:
payload = _tickers_payload([
_ticker("BTCUSDT", "99999.5", "100000.5"),
_ticker("DEADUSDT", "0", "0"), # zero quotes → skip
_ticker("CROSSUSDT", "101", "100"), # crossed → skip
{"symbol": "NOQUOTE"}, # missing fields → skip
{"bid1Price": "1", "ask1Price": "2"}, # missing symbol → skip
"not-a-dict", # garbage row → skip
])
out = BybitTrueSpread(fetch=lambda _u: payload, sleep=lambda _: None).real_spread_bps()
assert set(out) == {"BTCUSDT"}
def test_fetcher_universe_filter() -> None:
payload = _tickers_payload([
_ticker("BTCUSDT", "99999.5", "100000.5"),
_ticker("ETHUSDT", "1999.98", "2000.02"),
_ticker("SOLUSDT", "149.99", "150.01"),
])
out = BybitTrueSpread(fetch=lambda _u: payload, sleep=lambda _: None).real_spread_bps(
universe={"BTCUSDT", "SOLUSDT"})
assert set(out) == {"BTCUSDT", "SOLUSDT"}
def test_fetcher_snapshot_is_clock_deterministic() -> None:
payload = _tickers_payload([_ticker("BTCUSDT", "99999.5", "100000.5")])
snap = BybitTrueSpread(fetch=lambda _u: payload, sleep=lambda _: None,
clock=_FIXED_CLOCK).snapshot()
assert snap["fetched_at"] == _NOW # stamped from the injected clock, not wall clock
assert snap["n"] == 1
assert "BTCUSDT" in snap["spreads"] and "BTCUSDT" in snap["last_price"]
def test_fetcher_empty_list_is_empty() -> None:
out = BybitTrueSpread(fetch=lambda _u: _tickers_payload([]), sleep=lambda _: None).real_spread_bps()
assert out == {}
# --- Part B: fixtures (mirror the maker-recost seeds) ------------------------------------------------
def _ohlc_from_close(close: float, spread_frac: float, vol_frac: float, phase: float) -> tuple[float, float]:
mid = close
vol = vol_frac * (0.5 + 0.5 * math.cos(7.0 * phase))
half = 0.5 * spread_frac + vol
return mid * (1.0 + half), mid * (1.0 - half)
def _seed_wide_spread_book(store: TimescaleFeatureStore, *, days: int = 400) -> None:
"""A carry book whose coins carry a WIDE measured (CS) spread — so the maker leg has a lot to save AND a
tiny REAL quote has a lot LESS to save (the direction the whole exercise is about)."""
funding = {"AAAUSDT": 0.0012, "BBBUSDT": 0.0012, "CCCUSDT": 0.0012,
"XXXUSDT": -0.0012, "YYYUSDT": -0.0012, "ZZZUSDT": -0.0012}
wide = 0.02 # 200 bps measured CS spread
for idx, (sym, fund) in enumerate(funding.items()):
rows = []
px = 100.0
for d in range(days):
px *= (1.0 + 0.0005 * math.sin(0.3 * d + idx))
high, low = _ohlc_from_close(px, wide, vol_frac=0.0005, phase=0.3 * d + idx)
rows.append((d * _DAY, {"funding": fund + 0.0005 * math.cos(0.5 * d + idx),
"close": px, "spot_close": 100.0, "high": high, "low": low}))
store.write_features(sym, rows)
for sym in funding:
store.write_features(sym, [(d * _DAY, {"turnover": 50_000_000.0}) for d in range(days)])
def _wide_book_detail() -> dict:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_wide_spread_book(store)
pc = per_coin_book_returns(store, universe=None, sleeves=["xsfunding"], unlock_events=[],
return_detail=True)
store.close()
assert pc["available"], pc
return pc
# --- Part B: the cost-model override ----------------------------------------------------------------
def test_f0_real_spread_ties_to_taker_plus_half_real() -> None:
"""f=0 + a real_spread override reproduces the live all-taker cost formula but with the REAL spread:
cost_drag == taker_fee + 0.5·real_spread (since every coin shares the same injected real spread)."""
pc = _wide_book_detail()
real = {c: 3.0 for c in pc["spread_by_coin"]} # 3bp real quote on every book coin
r = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0, real_spread_by_coin=real)
assert abs(r["cost_drag_bps"] - (5.5 + 0.5 * 3.0)) < 1e-9
def test_real_override_reproduces_blended_math() -> None:
"""A real_spread override drives the SAME blended_cost_bps math — just with the injected spread per coin."""
pc = _wide_book_detail()
real = {c: 4.0 for c in pc["spread_by_coin"]}
r = recost_net_series(pc, fill_fraction=0.7, adverse_sel=0.25, real_spread_by_coin=real)
expected = blended_cost_bps(spread_bps=4.0, fill_fraction=0.7, adverse_sel=0.25)
assert abs(r["cost_drag_bps"] - expected) < 1e-9
def test_fallback_to_cs_for_unquoted_coins() -> None:
"""A coin with NO live quote falls back to its CS estimate; a quoted coin uses the real spread."""
pc = _wide_book_detail()
coins = sorted(pc["spread_by_coin"])
real = {coins[0]: 2.0} # only one coin has a live quote; the rest fall back to ~200bp CS
full_real = {c: 2.0 for c in coins}
partial = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0, real_spread_by_coin=real)
all_real = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0, real_spread_by_coin=full_real)
cs_only = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0)
# partial (1 real, rest CS) sits strictly between all-real (cheap) and all-CS (expensive).
assert all_real["cost_drag_bps"] < partial["cost_drag_bps"] < cs_only["cost_drag_bps"]
# --- Part B: the DIRECTION (tighter real spread → less to save) -------------------------------------
def test_real_spread_lifts_baseline_and_shrinks_maker_lift() -> None:
"""The core finding's direction: with a real spread MUCH tighter than the CS proxy, the all-taker baseline
is CHEAPER (higher Sharpe, lower cost-drag) AND the maker lift is SMALLER — there is less spread left to
save. The lift is measured in COST-DRAG bps (the mathematically clean measure: maker saves at most
(0.5adverse)·spread per unit turnover, so a tiny real spread caps the saving at a tiny number, whereas the
inflated CS spread leaves tens of bps to save). ΔSharpe is a nonlinear function of cost and is NOT a
reliable direction proxy on a synthetic near-zero-mean book, so the cost-drag saving is asserted here."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_wide_spread_book(store)
pc = per_coin_book_returns(store, universe=None, sleeves=["xsfunding"], unlock_events=[],
return_detail=True)
real = {c: 2.0 for c in pc["spread_by_coin"]} # 2bp real vs ~200bp CS
rep = maker_recost_sweep(store, universe=None, sleeves=["xsfunding"], capital=100_000.0,
unlock_events=[], real_spread_by_coin=real)
store.close()
assert "real" in rep
# 1) baseline (all-taker) is CHEAPER under REAL → higher Sharpe, lower cost-drag.
assert rep["real"]["baseline"]["sharpe"] > rep["baseline"]["sharpe"]
assert rep["real"]["baseline"]["cost_drag_bps"] < rep["baseline"]["cost_drag_bps"]
# 2) the maker lift (cost-drag REDUCTION the maker leg buys vs the all-taker baseline) is far SMALLER under
# REAL — tighter real spread → less to save.
cs_saving = abs(rep["operating_point"]["delta_cost_drag_bps"])
real_saving = abs(rep["real"]["operating_point"]["delta_cost_drag_bps"])
assert real_saving < cs_saving
def test_sweep_without_real_override_unchanged() -> None:
"""When no real override is given, the sweep result has NO `real` key (back-compat — the CS grid alone)."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_wide_spread_book(store)
rep = maker_recost_sweep(store, universe=None, sleeves=["xsfunding"], capital=100_000.0, unlock_events=[])
store.close()
assert rep["available"] is True
assert "real" not in rep
# --- Part B: compare_spreads (the over-estimate factor) ---------------------------------------------
def test_compare_spreads_reports_ratio_and_fallback() -> None:
cs = {"BTCUSDT": 144.0, "ETHUSDT": 140.0, "SOLUSDT": 160.0, "THINUSDT": 300.0}
real = {"BTCUSDT": 1.0, "ETHUSDT": 2.0, "SOLUSDT": 3.0} # THINUSDT has no live quote
cmp = compare_spreads(cs, real)
assert cmp["n_coins"] == 4
assert cmp["n_real"] == 3 and cmp["n_fallback"] == 1
assert abs(cmp["median_real_spread_bps"] - 2.0) < 1e-9
assert abs(cmp["median_cs_spread_bps"] - 152.0) < 1e-9 # median(140,144,160,300)
assert cmp["cs_over_real_ratio"] > 50.0 # CS massively over-states the real quote
# --- CLI smoke (mocked store + mocked fetcher, NO network) ------------------------------------------
def _seed_for_cli(store: TimescaleFeatureStore) -> None:
_seed_wide_spread_book(store, days=400)
class _FakeTrueSpread:
"""A BybitTrueSpread stand-in that serves a tight live quote for the book coins — NO network."""
def __init__(self, *_a, **_k) -> None: # noqa: ANN002, ANN003 — match the real ctor signature
pass
def real_spread_bps(self, *, universe=None): # noqa: ANN001, ANN202
coins = universe or {"AAAUSDT", "BBBUSDT", "CCCUSDT", "XXXUSDT", "YYYUSDT", "ZZZUSDT"}
return {c: 2.0 for c in coins}
def test_cli_bybit_true_spread_prints_comparison(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_for_cli(seeded)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *a, **k: seeded, raising=False)
monkeypatch.setattr("fxhnt.adapters.data.bybit_true_spread.BybitTrueSpread",
_FakeTrueSpread, raising=False)
result = CliRunner().invoke(cli.app, ["bybit-true-spread", "--min-dollar-vol", "0"])
seeded.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "real quoted spread" in out
assert "corwin" in out or "high/low" in out
assert "over-states" in out
def test_cli_maker_recost_real_spread_prints_both_grids(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_for_cli(seeded)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *a, **k: seeded, raising=False)
monkeypatch.setattr("fxhnt.adapters.data.bybit_true_spread.BybitTrueSpread",
_FakeTrueSpread, raising=False)
result = CliRunner().invoke(cli.app, ["maker-recost", "--min-dollar-vol", "0", "--real-spread"])
seeded.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "cs spread" in out and "real spread" in out
assert "caveat" in out
assert "current snapshot" in out
def test_true_spread_report_over_book_coins() -> None:
"""Part-A report: builds the book, compares its coins' REAL vs CS spread, returns the ratio + fallbacks."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_wide_spread_book(store)
pc = per_coin_book_returns(store, universe=None, sleeves=["xsfunding"], unlock_events=[],
return_detail=True)
real = {c: 2.0 for c in pc["spread_by_coin"]}
store2 = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_wide_spread_book(store2)
rep = true_spread_report(store2, universe=None, sleeves=["xsfunding"], real_spread_by_coin=real,
unlock_events=[])
store.close()
store2.close()
assert rep["available"] is True
assert abs(rep["median_real_spread_bps"] - 2.0) < 1e-9
assert rep["median_cs_spread_bps"] > 100.0 # the inflated high/low proxy
assert rep["cs_over_real_ratio"] > 10.0