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>
331 lines
15 KiB
Python
331 lines
15 KiB
Python
"""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 Corwin–Schultz
|
||
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 (ask−bid)/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.5−adverse)·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
|