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>
121 lines
5.5 KiB
Python
121 lines
5.5 KiB
Python
"""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"
|