diff --git a/src/fxhnt/application/slippage.py b/src/fxhnt/application/slippage.py new file mode 100644 index 0000000..4a64ae6 --- /dev/null +++ b/src/fxhnt/application/slippage.py @@ -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 diff --git a/tests/unit/test_slippage.py b/tests/unit/test_slippage.py new file mode 100644 index 0000000..82ee15d --- /dev/null +++ b/tests/unit/test_slippage.py @@ -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