Files
fxhnt/tests/integration/test_bybit_maker_recost.py
jgrusewski 12d0f11945 feat(research): maker-vs-taker re-costing of the Bybit 4-edge book — net-Sharpe lift from passive fills
Execution-only what-if: re-cost the deployed liquid-61 book's turnover under a maker
FILL FRACTION f, holding the GROSS book return and per-day per-coin turnover FIXED
(reused from per_coin_book_returns) so the only variable is the per-turnover cost model
— nothing to overfit.

Cost model per coin per rebalance, |Δw| split by f:
  taker leg (1−f): 5.5bp fee + 0.5·CS spread   (crosses the spread — current live model)
  maker leg (f):   2.0bp fee + adverse_sel·spread (does NOT cross; adverse_sel ∈ [0,0.5])
  blended = f·maker + (1−f)·taker

- per_coin_book_returns: optional return_detail exposing spread_by_coin + daily_turnover
  (existing keys untouched → f=0 net is byte-identical, baseline tie-out cannot drift)
- bybit_maker_recost: blended_cost_bps, recost_net_series, maker_recost_sweep over
  f ∈ {0,0.3,0.5,0.7,1.0} × adverse_sel ∈ {0,0.25} with ΔSharpe/Δcost-drag vs the f=0
  all-taker baseline; metrics via paper_sim._metrics (same space as _bybit_measured_net)
- CLI `fxhnt maker-recost` (--min-dollar-vol 10000000 = liquid-61), READ-ONLY
- tests: f=0==per_coin_book_returns & _bybit_measured_net (tie-out); f=1/adverse=0 →
  maker-fee-only lower drag+higher net; adverse monotonicity; gross identical across all
  scenarios; read-only + memory-bound. Full suite 1658 passed.

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

386 lines
18 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.
"""Maker-vs-taker EXECUTION re-costing of the Bybit 4-edge book — net-Sharpe lift from passive (maker) fills.
This is NOT a signal change and NOT a fit: the GROSS book return is UNCHANGED across every scenario; only the
per-turnover COST model varies (taker crosses the spread; maker does not). So there is nothing to overfit.
The cost model (per rebalance, per coin, |Δw| split by maker fill fraction f):
* taker leg (1f): taker_fee(5.5) + 0.5·spread (crosses the spread — the CURRENT live model)
* maker leg (f): maker_fee(2.0) + adverse_sel·spread (does NOT cross; adverse_sel ∈ [0, 0.5])
blended = f·maker + (1f)·taker.
Tests (TDD):
* f=0 reproduces `per_coin_book_returns`/`_bybit_measured_net`'s current net EXACTLY (baseline tie-out);
* f=1, adverse_sel=0 → maker-fee-only (no spread) → strictly lower cost-drag + higher net than baseline;
* adverse_sel=0.5 at f=1 → cost-drag between maker-fee-only and taker (monotone in adverse_sel);
* monotonicity: higher f → lower cost-drag (for adverse_sel < 0.5);
* GROSS is IDENTICAL across all scenarios (net + cost reconstructs the same gross — execution-only);
* the sweep reports the grid + baseline deltas + median spread + avg daily turnover;
* READ-ONLY (WriteTripwire) + memory-bounded; empty store → unavailable; CLI smoke (mocked store).
NO network — the store is in-memory SQLite; external inputs are injected.
"""
from __future__ import annotations
import math
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 (
_ADVERSE_SELS,
_FILL_FRACTIONS,
_MAKER_FEE_BPS,
blended_cost_bps,
maker_recost_sweep,
recost_net_series,
)
_DAY = 86_400
# --- fixtures (mirror the liquidity-sweep test 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_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int = 400,
drift: float = 0.01, spread_frac: float = 0.0005) -> list[str]:
syms = [f"SYM{idx:02d}USDT" for idx in range(n_symbols)]
for idx, sym in enumerate(syms):
sign = 1.0 if idx % 2 == 0 else -1.0
px = 100.0
rows = []
for d in range(days):
wobble = 0.003 * math.cos(0.4 * d + idx)
px *= (1.0 + sign * drift + wobble)
high, low = _ohlc_from_close(px, spread_frac, vol_frac=0.0005, phase=0.4 * d + idx)
rows.append((d * _DAY, {"close": px, "high": high, "low": low}))
store.write_features(sym, rows)
return syms
def _seed_carry(store: TimescaleFeatureStore, *, days: int = 400, spread_frac: float = 0.0005) -> list[str]:
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.0002 * math.cos(0.5 * d + idx)
px = 100.0
high, low = _ohlc_from_close(px, spread_frac, vol_frac=0.0005, phase=0.5 * d + idx)
rows.append((d * _DAY, {"funding": f, "close": px, "spot_close": px, "high": high, "low": low}))
store.write_features(sym, rows)
return list(funding)
def _seed_turnover(store: TimescaleFeatureStore, dollar_vol_by_sym: dict[str, float], *,
days: int = 400) -> None:
for sym, dv in dollar_vol_by_sym.items():
store.write_features(sym, [(d * _DAY, {"turnover": dv}) for d in range(days)])
def _seed_wide_spread_book(store: TimescaleFeatureStore, *, days: int = 400) -> None:
"""A carry book whose coins carry a WIDE measured spread (so the maker leg has a lot of spread to save)."""
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 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)
_seed_turnover(store, {s: 50_000_000.0 for s in funding}, days=days)
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: maker-recost called write method {name!r}")
return getattr(self._inner, name)
class _FullPanelTripwireStore:
_FULL_PANELS = frozenset({"crypto_close_panel", "crypto_funding_panel", "crypto_spot_panel"})
def __init__(self, inner: TimescaleFeatureStore) -> None:
object.__setattr__(self, "_inner", inner)
object.__setattr__(self, "read_panel_calls", [])
def read_panel(self, symbols, feature): # noqa: ANN001, ANN201
self.read_panel_calls.append((tuple(symbols), feature))
return self._inner.read_panel(symbols, feature)
def __getattr__(self, name: str):
if name in _FullPanelTripwireStore._FULL_PANELS:
raise AssertionError(f"MEMORY-BOUND violation: maker-recost read the FULL panel {name!r}")
return getattr(self._inner, name)
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
# --- THE COST MODEL ------------------------------------------------------------------------------
def test_blended_at_f0_equals_taker() -> None:
"""f=0 ⇒ blended == the live all-taker cost (taker_fee + half the measured spread), for any adverse_sel."""
for adverse in (0.0, 0.25, 0.5):
c = blended_cost_bps(spread_bps=80.0, fill_fraction=0.0, adverse_sel=adverse,
taker_fee_bps=5.5, maker_fee_bps=2.0)
assert abs(c - (5.5 + 0.5 * 80.0)) < 1e-12, (adverse, c)
def test_blended_at_f1_adverse0_is_maker_fee_only() -> None:
"""Fully passive + optimistic (maker earns the spread) ⇒ cost = maker fee, NO spread term."""
c = blended_cost_bps(spread_bps=120.0, fill_fraction=1.0, adverse_sel=0.0,
taker_fee_bps=5.5, maker_fee_bps=2.0)
assert abs(c - 2.0) < 1e-12, c
def test_blended_monotone_decreasing_in_fill_fraction() -> None:
"""For adverse_sel < 0.5 the maker leg is cheaper than the taker leg, so more passive fill ⇒ lower cost."""
prev = math.inf
for f in (0.0, 0.3, 0.5, 0.7, 1.0):
c = blended_cost_bps(spread_bps=100.0, fill_fraction=f, adverse_sel=0.25,
taker_fee_bps=5.5, maker_fee_bps=2.0)
assert c < prev, (f, c, prev)
prev = c
def test_blended_monotone_increasing_in_adverse_sel() -> None:
"""At f=1 the cost rises with adverse_sel; at adverse_sel=0.5 it sits between maker-fee-only and taker."""
sp = 100.0
maker_only = blended_cost_bps(spread_bps=sp, fill_fraction=1.0, adverse_sel=0.0)
half = blended_cost_bps(spread_bps=sp, fill_fraction=1.0, adverse_sel=0.5)
taker = blended_cost_bps(spread_bps=sp, fill_fraction=0.0, adverse_sel=0.0)
assert maker_only < half < taker, (maker_only, half, taker)
# adverse_sel=0.5 at f=1 ⇒ only the fee differs from taker (same spread term).
assert abs(half - (_MAKER_FEE_BPS + 0.5 * sp)) < 1e-12, half
def test_blended_clamps_negative_spread() -> None:
assert blended_cost_bps(spread_bps=-50.0, fill_fraction=1.0, adverse_sel=0.0) == _MAKER_FEE_BPS
# --- BASELINE TIE-OUT (f=0 == current deployed number) -------------------------------------------
def test_f0_reproduces_per_coin_book_net_exactly() -> None:
"""The CORE sanity tie-out: re-costing at f=0 reproduces the live all-taker `per_coin_book_returns` net
series BYTE-IDENTICALLY — proves the re-costing introduces no drift over the deployed number."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(store)
carry = _seed_carry(store)
_seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
pc = per_coin_book_returns(store, universe=None, unlock_events=[], return_detail=True)
store.close()
r = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0)
assert r["net_series"] == pc["net_series"]
def test_f0_ties_to_bybit_measured_net() -> None:
"""The baseline equity-curve metrics (f=0) match the deployed `_bybit_measured_net` — same metric space."""
from fxhnt.application.compare_measured_cost import _bybit_measured_net
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(store)
carry = _seed_carry(store)
_seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
deployed = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
universe=None, unlock_events=[])
rep = maker_recost_sweep(store, universe=None, capital=100_000.0, unlock_events=[])
store.close()
assert rep["available"] is True
assert abs(rep["baseline"]["sharpe"] - deployed["metrics"]["sharpe"]) < 1e-9
assert abs(rep["baseline"]["total_return"] - deployed["metrics"]["total_return"]) < 1e-9
assert abs(rep["baseline"]["cost_drag_bps"] - deployed["cost_drag_bps"]) < 1e-9
# --- THE LIFT ------------------------------------------------------------------------------------
def test_f1_adverse0_strictly_lower_drag_and_higher_net_than_baseline() -> None:
"""On a WIDE-spread book, fully passive + optimistic fills slash the cost drag (to ~maker fee) and lift
the net cumulative return above the all-taker baseline (the spread crossing is the dominant cost)."""
pc = _wide_book_detail()
base = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0)
maker = recost_net_series(pc, fill_fraction=1.0, adverse_sel=0.0)
assert maker["cost_drag_bps"] < base["cost_drag_bps"], (maker["cost_drag_bps"], base["cost_drag_bps"])
assert abs(maker["cost_drag_bps"] - _MAKER_FEE_BPS) < 1e-9, maker["cost_drag_bps"]
assert sum(maker["net_series"].values()) > sum(base["net_series"].values())
def test_adverse_sel_half_drag_between_maker_only_and_taker() -> None:
"""At f=1, adverse_sel=0.5's cost drag sits strictly between maker-fee-only (adverse 0) and the all-taker
baseline — monotone in adverse_sel, as the model prescribes."""
pc = _wide_book_detail()
maker_only = recost_net_series(pc, fill_fraction=1.0, adverse_sel=0.0)["cost_drag_bps"]
half = recost_net_series(pc, fill_fraction=1.0, adverse_sel=0.5)["cost_drag_bps"]
taker = recost_net_series(pc, fill_fraction=0.0, adverse_sel=0.0)["cost_drag_bps"]
assert maker_only < half < taker, (maker_only, half, taker)
def test_cost_drag_monotone_decreasing_in_fill_fraction() -> None:
"""Higher maker fill fraction ⇒ strictly lower turnover-weighted cost drag (for adverse_sel < 0.5)."""
pc = _wide_book_detail()
prev = math.inf
for f in (0.0, 0.3, 0.5, 0.7, 1.0):
drag = recost_net_series(pc, fill_fraction=f, adverse_sel=0.25)["cost_drag_bps"]
assert drag < prev, (f, drag, prev)
prev = drag
def test_gross_is_identical_across_all_scenarios() -> None:
"""Execution-only proof: for EVERY (f, adverse_sel), net[d] + day_cost[d] reconstructs the SAME gross[d]
— the GROSS book return is untouched (no signal change, nothing to overfit)."""
pc = _wide_book_detail()
gross = pc["gross_series"]
for adverse in (0.0, 0.25, 0.5):
for f in (0.0, 0.5, 1.0):
r = recost_net_series(pc, fill_fraction=f, adverse_sel=adverse)
for d in gross:
assert abs((r["net_series"][d] + r["day_cost"][d]) - gross[d]) < 1e-12, (f, adverse, d)
# the input gross dict is never mutated by re-costing.
assert pc["gross_series"] == gross
def test_return_detail_does_not_change_existing_keys() -> None:
"""Adding return_detail=True must not alter the existing all-taker net (so the tie-out cannot drift)."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(store)
carry = _seed_carry(store)
_seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
plain = per_coin_book_returns(store, universe=None, unlock_events=[])
detail = per_coin_book_returns(store, universe=None, unlock_events=[], return_detail=True)
store.close()
assert plain["net_series"] == detail["net_series"]
assert plain["gross_series"] == detail["gross_series"]
assert "spread_by_coin" in detail and "daily_turnover" in detail
assert "spread_by_coin" not in plain
# --- THE SWEEP -----------------------------------------------------------------------------------
def test_sweep_runs_full_grid_with_baseline_deltas() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(store)
carry = _seed_carry(store)
_seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
rep = maker_recost_sweep(store, universe=None, capital=100_000.0, unlock_events=[])
store.close()
assert rep["available"] is True
assert rep["taker_fee_bps"] == 5.5 and rep["maker_fee_bps"] == 2.0
assert len(rep["table"]) == len(_FILL_FRACTIONS) * len(_ADVERSE_SELS)
for row in rep["table"]:
assert row["fill_fraction"] in _FILL_FRACTIONS
assert row["adverse_sel"] in _ADVERSE_SELS
assert math.isfinite(row["sharpe"])
assert math.isfinite(row["delta_sharpe"])
assert math.isfinite(row["cost_drag_bps"])
# the f=0 rows ARE the baseline → zero delta.
for row in rep["table"]:
if row["fill_fraction"] == 0.0:
assert abs(row["delta_sharpe"]) < 1e-9
assert abs(row["delta_cost_drag_bps"]) < 1e-9
assert "median_spread_bps" in rep and "avg_daily_turnover" in rep
assert "operating_point" in rep and "gross_metrics" in rep
def test_sweep_lift_is_capital_robust() -> None:
"""The cost is capital-independent per-bp, so the per-cell Sharpe (and the lift) is identical across very
different capitals."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(store)
carry = _seed_carry(store)
_seed_turnover(store, {s: 50_000_000.0 for s in syms + carry})
small = maker_recost_sweep(store, universe=None, capital=100_000.0, unlock_events=[])
large = maker_recost_sweep(store, universe=None, capital=50_000_000.0, unlock_events=[])
store.close()
sa = {(r["fill_fraction"], r["adverse_sel"]): r["sharpe"] for r in small["table"]}
la = {(r["fill_fraction"], r["adverse_sel"]): r["sharpe"] for r in large["table"]}
assert sa == la
def test_sweep_empty_store_reports_unavailable() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
rep = maker_recost_sweep(store, universe=None, unlock_events=[])
store.close()
assert rep["available"] is False
assert "reason" in rep
# --- READ-ONLY + memory-bound --------------------------------------------------------------------
def test_sweep_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(inner)
carry = _seed_carry(inner)
_seed_turnover(inner, {s: 50_000_000.0 for s in syms + carry})
guarded = _WriteTripwireStore(inner)
rep = maker_recost_sweep(guarded, universe=None, capital=100_000.0, unlock_events=[])
inner.close()
assert rep["available"] is True
def test_sweep_universe_restricted_reads_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(inner, n_symbols=8)
_seed_turnover(inner, {s: 50_000_000.0 for s in syms})
guarded = _FullPanelTripwireStore(inner)
universe = {"SYM00USDT", "SYM02USDT", "SYM04USDT", "SYM06USDT"}
maker_recost_sweep(guarded, universe=universe, capital=100_000.0, unlock_events=[])
inner.close()
assert guarded.read_panel_calls
for symbols, _feature in guarded.read_panel_calls:
assert set(symbols) <= universe
# --- CLI smoke (mocked store) --------------------------------------------------------------------
def test_cli_maker_recost_prints_table(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
seeded = TimescaleFeatureStore("sqlite://", table="bybit_features")
syms = _seed_momentum(seeded, n_symbols=8, days=400)
carry = _seed_carry(seeded, days=400)
_seed_turnover(seeded, {s: 50_000_000.0 for s in syms + carry}, days=400)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *a, **k: seeded, raising=False)
result = CliRunner().invoke(cli.app, ["maker-recost", "--capital", "100000", "--min-dollar-vol", "0"])
seeded.close()
assert result.exit_code == 0, result.output
out_lower = result.output.lower()
assert "maker" in out_lower and "taker" in out_lower
assert "baseline" in out_lower
assert "operating point" in out_lower
assert "gross book return is unchanged" in out_lower