Files
fxhnt/tests/unit/test_smart_router.py
jgrusewski 124edda582 fix(exec): stable data-derived rebalance_id + rate-limit signal + cover/drift invariant tests
Fix 1: derive rebalance_id from last panel day (reb<day_int>) instead of
wall-clock strftime so crash+re-run in a different minute reuses the same
deterministic clientOrderIds — Binance dedupes on clientOrderId → idempotent.

Fix 2: add rate_limited: bool field to RebalancePlan; set True whenever any
placed Fill has status == "REJECTED" (budget-exhausted leg); surface in CLI echo
so operators see exhaustion explicitly rather than learning via a drift halt.

Fix 3: test_reduce_only_at_exact_cover_boundary locks the boundary invariant
that an order sized to exactly cover an open position (qty == |current_qty|) is
always flagged reduce_only for both SELL-covers-long and BUY-covers-short cases.

Fix 4: test_reconcile_flags_unexpected_drift_on_untraded_symbol proves
reconciliation catches drift on holdings the orchestrator never touched this
cycle (the actual-union path in diff_positions covers untraded symbols too).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:39:29 +02:00

121 lines
5.5 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.
"""smart_router.plan_orders: sizing, caps, dust/min-notional skip, maker+abort pricing, reduce_only."""
from __future__ import annotations
from fxhnt.domain.execution.smart_router import RouteInput, plan_orders
from fxhnt.ports.crypto_exchange import OrderBook, SymbolFilter
F = SymbolFilter(symbol="X", step_size=0.001, tick_size=0.1, min_notional=5.0)
OB = OrderBook(symbol="X", bid=100.0, ask=100.2, bid_qty=10.0, ask_qty=10.0) # depth ~1000 USD/side
def _inp(delta_usd, pos_qty=0.0, adv=1e9, max_slip=25.0, part=0.5, depth=0.5):
return RouteInput(symbol="X", delta_usd=delta_usd, current_qty=pos_qty, filt=F, book=OB,
adv_usd=adv, participation_rate=part, depth_cap=depth, max_slippage_bps=max_slip)
def test_buy_maker_at_bid_with_abort() -> None:
p = plan_orders([_inp(500.0)])[0]
assert p.side == "BUY" and p.skip_reason is None
assert p.price == 100.0 # maker buy posts at the bid (touch)
# 500 USD / mid 100.1 = 4.995..., floored to step 0.001 = 4.995
assert abs(p.qty - 4.995) < 1e-9
assert p.abort_price > OB.mid # buy abort is above mid (max adverse)
def test_min_notional_skip() -> None:
p = plan_orders([_inp(3.0)])[0]
assert p.skip_reason == "min_notional"
def test_dust_skip() -> None:
# delta_usd=0.01 -> use_usd/mid rounds to 0.0 after step_size floor -> "dust"
p = plan_orders([_inp(0.01)])[0]
assert p.skip_reason == "dust"
def test_no_book_when_zero_depth() -> None:
# Real prices but zero depth on both sides -> no_book
ob_zero = OrderBook(symbol="X", bid=100.0, ask=100.2, bid_qty=0.0, ask_qty=0.0)
inp_buy = RouteInput(symbol="X", delta_usd=500.0, current_qty=0.0, filt=F, book=ob_zero,
adv_usd=1e9, participation_rate=0.5, depth_cap=0.5, max_slippage_bps=25.0)
p_buy = plan_orders([inp_buy])[0]
assert p_buy.skip_reason == "no_book"
inp_sell = RouteInput(symbol="X", delta_usd=-500.0, current_qty=0.0, filt=F, book=ob_zero,
adv_usd=1e9, participation_rate=0.5, depth_cap=0.5, max_slippage_bps=25.0)
p_sell = plan_orders([inp_sell])[0]
assert p_sell.skip_reason == "no_book"
# Zero prices also trigger no_book
ob_no_px = OrderBook(symbol="X", bid=0.0, ask=0.0, bid_qty=10.0, ask_qty=10.0)
inp_nopx = RouteInput(symbol="X", delta_usd=500.0, current_qty=0.0, filt=F, book=ob_no_px,
adv_usd=1e9, participation_rate=0.5, depth_cap=0.5, max_slippage_bps=25.0)
p_nopx = plan_orders([inp_nopx])[0]
assert p_nopx.skip_reason == "no_book"
def test_participation_and_depth_cap_limit_size() -> None:
# huge target, ADV tiny -> participation cap binds
p = plan_orders([_inp(1_000_000.0, adv=2000.0, part=0.05, depth=0.5)])[0]
assert p.qty * OB.mid <= 0.05 * 2000.0 + 1e-6 # capped to participation×ADV (=100 USD)
assert p.capped is True
def test_reduce_only_when_trade_reduces_position() -> None:
# short 10 base; a BUY of 2 base reduces -> reduce_only
p = plan_orders([_inp(200.0, pos_qty=-10.0)])[0]
assert p.side == "BUY" and p.reduce_only is True
def test_reduce_only_false_when_increasing() -> None:
# long 10 base; buying more increases the position -> NOT reduce_only
p = plan_orders([_inp(200.0, pos_qty=10.0)])[0]
assert p.side == "BUY" and p.reduce_only is False
def test_reduce_only_false_when_flipping() -> None:
# short 1 base; buying enough to over-cover flips to long -> NOT reduce_only
p = plan_orders([_inp(500.0, pos_qty=-1.0)])[0]
assert p.side == "BUY" and p.reduce_only is False
# qty must exceed the absolute short (1.0) so it genuinely flips
assert p.qty > 1.0
def test_sell_maker_at_ask() -> None:
p = plan_orders([_inp(-500.0)])[0]
assert p.side == "SELL" and p.price == 100.2 and p.abort_price < OB.mid
def test_reduce_only_at_exact_cover_boundary() -> None:
"""Fix 3: an order sized to exactly cover the open position must always be reduce_only.
The router sizes in base units as floor(usd / mid, step_size). For a pos_qty long,
we set delta_usd to produce a qty that precisely equals pos_qty after step-floor, so
the cover is exact (qty == current_qty, the boundary case). We verify reduce_only is
True — the invariant that an exact cover never accidentally opens new exposure.
Arithmetic:
mid = (100.0 + 100.2) / 2 = 100.1
For pos_qty = 4.995 (already a multiple of step_size=0.001):
qty = floor(4.995 * 100.1 / 100.1, 0.001) = floor(4.995, 0.001) = 4.995 ✓
"""
mid = OB.mid # 100.1
# SELL that exactly covers a long at the boundary.
pos_qty = 4.995 # a clean multiple of step_size; floor(pos_qty * mid / mid) = pos_qty
delta_sell = -(pos_qty * mid)
p_sell = plan_orders([_inp(delta_sell, pos_qty=pos_qty)])[0]
assert p_sell.side == "SELL"
assert p_sell.skip_reason is None, f"order skipped unexpectedly: {p_sell.skip_reason}"
assert p_sell.qty == pos_qty, f"expected qty=={pos_qty}, got {p_sell.qty}"
assert p_sell.reduce_only is True, "exact SELL-covers-long boundary must be reduce_only"
# BUY that exactly covers a short at the boundary.
short_qty = -4.995
delta_buy = abs(short_qty) * mid
p_buy = plan_orders([_inp(delta_buy, pos_qty=short_qty)])[0]
assert p_buy.side == "BUY"
assert p_buy.skip_reason is None, f"order skipped unexpectedly: {p_buy.skip_reason}"
assert p_buy.qty == abs(short_qty), f"expected qty=={abs(short_qty)}, got {p_buy.qty}"
assert p_buy.reduce_only is True, "exact BUY-covers-short boundary must be reduce_only"