feat(exec): pure decomposed slippage model

execution_slippage_bps / timing_drift_bps / slippage_vs_assumed_bps —
pure float math (spec §3) for the Bybit testnet real-paper leg. BUY/SELL
sign, bps math, None-guards on non-positive mid/book_mark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-28 14:04:03 +02:00
parent d64af9ab00
commit bd73e02774
2 changed files with 92 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
"""Pure decomposed-slippage measurement model (design §3) — the testnet real-paper leg's first-class output.
Three pure functions over floats (no I/O, no network) so the bps math is unit-tested in isolation. The Bybit
execution adapter supplies `fill_price`, `mid_at_order` (the live order-book mid AT order time) and `book_mark`
(the book's mark/close the targets were sized off); these split the total fill-vs-mark move into:
* `execution_slippage_bps` — fill vs the live mid: what the A8 cost model claims to capture (the 5/8/6/35bp
taker+spread tiers). >0 = paid worse than mid.
* `timing_drift_bps` — the live mid vs the book mark: the close→execution price move, a SEPARATE effect.
Conflating the two (v1's bug) made the number meaningless; the decomposition is the whole point.
`slippage_vs_assumed_bps` is the validation delta vs the per-symbol assumed cost tier. Sign convention:
`+1` for BUY, `-1` for SELL, so a positive bp is always an adverse cost regardless of side. Guards: a
non-positive `mid_at_order`/`book_mark` (empty/one-sided thin testnet book) returns `None` — never a
divide-by-zero or a garbage bp; the caller flags such a fill `low_confidence` and excludes it from the verdict.
"""
from __future__ import annotations
def _sign(side: str) -> int:
return 1 if side.upper() == "BUY" else -1
def execution_slippage_bps(fill: float, mid_at_order: float, side: str) -> float | None:
"""Fill vs the live mid at order time, in bps (>0 = paid worse than mid). `None` if `mid_at_order <= 0`."""
if mid_at_order <= 0.0:
return None
return _sign(side) * (fill - mid_at_order) / mid_at_order * 1e4
def timing_drift_bps(mid_at_order: float, book_mark: float, side: str) -> float | None:
"""The live mid vs the book mark, in bps (>0 = market moved adversely since the mark). `None` if
`book_mark <= 0`."""
if book_mark <= 0.0:
return None
return _sign(side) * (mid_at_order - book_mark) / book_mark * 1e4
def slippage_vs_assumed_bps(execution_slippage: float, assumed_tier_bps: float) -> float:
"""The validation delta: measured execution slippage minus the assumed A8 cost tier (>0 = worse than
assumed)."""
return execution_slippage - assumed_tier_bps

View File

@@ -0,0 +1,50 @@
"""Pure decomposed-slippage model (spec §3). All math over floats — no I/O — so it is unit-tested directly.
The adapter supplies `fill_price`, `mid_at_order`, `book_mark`; the bps math is exercised here in isolation:
sign on both sides, a known fill/mid → exact bps, the decomposition sums to the fill-vs-mark total, the
assumed-tier delta, and the `mid<=0`/`book_mark<=0` None-guards (never a divide-by-zero or a garbage bp).
"""
from __future__ import annotations
from fxhnt.application.slippage import (
execution_slippage_bps,
slippage_vs_assumed_bps,
timing_drift_bps,
)
def test_buy_pays_above_mid_is_positive():
# bought at 101 vs mid 100 → +100 bps cost
assert execution_slippage_bps(101.0, 100.0, "BUY") == 100.0
def test_sell_below_mid_is_positive_cost():
# sold at 99 vs mid 100 → +100 bps cost
assert execution_slippage_bps(99.0, 100.0, "SELL") == 100.0
def test_timing_drift_sign():
# BUY, mid moved 100→101 from the book mark → +100 bps adverse drift
assert timing_drift_bps(101.0, 100.0, "BUY") == 100.0
def test_decomposition_sums_to_total():
fill, mid, mark = 102.0, 101.0, 100.0
exec_bps = execution_slippage_bps(fill, mid, "BUY")
timing_bps = timing_drift_bps(mid, mark, "BUY")
total = exec_bps + timing_bps
# The decomposition (spec §3) DEFINES total_fill_vs_mark_bps = execution + timing. The two terms use
# different denominators (mid for execution, mark for timing), so the sum reconstructs the single-
# denominator fill-vs-mark move ((102-100)/100*1e4 = 200) only up to that ~1bp compounding — it is NOT
# exactly 200 (the plan's `< 1e-6` assertion was off by this compounding term).
fill_vs_mark = (fill - mark) / mark * 1e4
assert abs(total - fill_vs_mark) < 1.0
def test_assumed_delta():
assert slippage_vs_assumed_bps(40.0, 35.0) == 5.0 # 5bp worse than the 35bp tier
def test_guards_none_on_bad_inputs():
assert execution_slippage_bps(101.0, 0.0, "BUY") is None
assert timing_drift_bps(101.0, -1.0, "SELL") is None