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>
386 lines
18 KiB
Python
386 lines
18 KiB
Python
"""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 (1−f): 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 + (1−f)·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
|