fix(exec): capital-risk fixes — authoritative IOC sizing, accumulate all legs, θ=0 hold, true arrival mid, dry-run gate report, rate-budget window
C2 (over-fill): after cancel, fetch `order_status` for the authoritative post-cancel fill qty instead of the stale place-response; maker may keep filling during the sleep so the stale qty would size the IOC too large. C1 (false-halt): accumulate BOTH the maker AND the IOC fills into `intended` so reconciliation sees the full executed quantity and does not false-halt on the IOC leg. I3 (dry-run observability): `block_reason` is now populated and returned even when `execute=False`, so monitoring can see gate trips on dry-runs. I4 (arrival mid): capture the true decision-time `book.mid` per symbol while building route inputs; pass that to `_persist` / TCA instead of the stale maker/abort blend. I5 (θ=0 hold): `gross_scaler == 0` (EMA/Kelly warm-up or NaN-recovery) now returns early with `block_reason="zero_gross_scale_hold"` rather than flattening the book. Deliberate exits belong to the kill-switch / crypto-flatten path. M7 (rate-budget window): `RateBudget.try_order` now resets the order counter after a 60-second window so a long-running process never permanently exhausts the per-minute budget. Tests: add FakeExchangePartialMaker + FakeExchangeMakerFillsDuringSleep variants and three new integration tests exercising the full maker→cancel→order_status→IOC path (previously zero coverage); add SELL-above-mid savings case to test_tca; add short position drift + expected-position-fully-closed cases to test_reconciliation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,15 +41,27 @@ def client_id(rebalance_id: str, symbol: str, leg: str) -> str:
|
||||
class RateBudget:
|
||||
"""Conservative per-minute governor. Orders are counted locally; weight is updated from the
|
||||
X-MBX-USED-WEIGHT response header the adapter reads after each call.
|
||||
The constructor accepts the effective limit directly (caller applies any safety margin)."""
|
||||
The constructor accepts the effective limit directly (caller applies any safety margin).
|
||||
A 60-second rolling window resets the order count so long-running processes never
|
||||
permanently exhaust the budget."""
|
||||
_WINDOW_S = 60.0
|
||||
|
||||
def __init__(self, max_orders: int = _MAX_ORDERS_PER_MIN,
|
||||
max_weight: int = _MAX_WEIGHT_PER_MIN) -> None:
|
||||
self._max_orders = max_orders
|
||||
self._max_weight = max_weight
|
||||
self._orders = 0
|
||||
self._used_weight = 0
|
||||
self._window_start: float | None = None # lazily initialised on first try_order call
|
||||
|
||||
def try_order(self) -> bool:
|
||||
now = time.time()
|
||||
if self._window_start is None:
|
||||
self._window_start = now
|
||||
elif now - self._window_start >= self._WINDOW_S:
|
||||
# New minute window — reset the per-minute order counter
|
||||
self._orders = 0
|
||||
self._window_start = now
|
||||
if self._orders >= self._max_orders:
|
||||
return False
|
||||
self._orders += 1
|
||||
@@ -175,3 +187,19 @@ class BinanceUsdmExchange:
|
||||
self._backoff(self._x.cancel_order, client_id, symbol, {"origClientOrderId": client_id})
|
||||
except Exception as e: # noqa: BLE001 — already gone is fine
|
||||
_log.debug("cancel %s: %s", client_id, e)
|
||||
|
||||
def order_status(self, symbol: str, client_id: str) -> Fill:
|
||||
"""Fetch the authoritative post-cancel fill state by clientOrderId.
|
||||
After a cancel completes the exchange reports the FINAL filled quantity — use this
|
||||
(not the stale place-response) to size the IOC remainder and avoid over-filling."""
|
||||
try:
|
||||
r = self._backoff(self._x.fetch_order, None, symbol, {"origClientOrderId": client_id})
|
||||
except Exception as e: # noqa: BLE001 — unknown/already-gone -> treat as nothing filled (safe)
|
||||
_log.debug("order_status %s: %s", client_id, e)
|
||||
return Fill(client_id, symbol, "", 0.0, 0.0, 0.0, "CANCELED")
|
||||
filled = float(r.get("filled") or 0.0)
|
||||
side = (r.get("side") or "").upper()
|
||||
status = "FILLED" if filled > 0 and (r.get("status") or "").lower() == "closed" else \
|
||||
("PARTIAL" if filled > 0 else (r.get("status") or "CANCELED").upper())
|
||||
return Fill(client_id, symbol, side, filled, float(r.get("average") or 0.0),
|
||||
float((r.get("fee") or {}).get("cost") or 0.0), status)
|
||||
|
||||
@@ -68,6 +68,16 @@ class CryptoExecutionService:
|
||||
scale = gross_scaler(edge_theta=edge_theta, kelly_fraction=kelly_fraction,
|
||||
kelly_cap=cfg.kelly_cap, gross_cap=cfg.gross_cap)
|
||||
|
||||
# I5: θ=0 (EMA/Kelly warm-up or NaN-recovery) HOLDS, never flattens.
|
||||
# Deliberate exits use the kill-switch or crypto-flatten CLI — not a transient zero scaler.
|
||||
if scale <= 0.0:
|
||||
return RebalancePlan(rebalance_id, 0, halted=False,
|
||||
block_reason="zero_gross_scale_hold", drift={})
|
||||
|
||||
# I4: capture the true decision-time mid per symbol for TCA (arrival_mid).
|
||||
# The order-book fetch already happens here for routing; store it now so the place loop
|
||||
# uses the ACTUAL bid/ask mid at decision time, not a blend of maker/abort prices.
|
||||
mids: dict[str, float] = {}
|
||||
inputs: list[RouteInput] = []
|
||||
for sym, w in capped.items():
|
||||
if sym not in filters:
|
||||
@@ -75,6 +85,7 @@ class CryptoExecutionService:
|
||||
target_usd = w * scale * acct.nlv
|
||||
cur_qty = acct.positions.get(sym, 0.0)
|
||||
book = self._x.order_book(sym)
|
||||
mids[sym] = book.mid # I4: authoritative arrival mid for TCA
|
||||
cur_usd = cur_qty * book.mid
|
||||
delta_usd = target_usd - cur_usd
|
||||
if abs(delta_usd) < cfg.hysteresis * acct.nlv:
|
||||
@@ -85,31 +96,40 @@ class CryptoExecutionService:
|
||||
max_slippage_bps=cfg.max_slippage_bps))
|
||||
|
||||
plans = [p for p in plan_orders(inputs) if p.skip_reason is None]
|
||||
|
||||
# I3: dry-run surfaces block_reason for monitoring even when not placing.
|
||||
if not execute:
|
||||
return RebalancePlan(rebalance_id, len(plans), halted=False, block_reason=None, drift={})
|
||||
return RebalancePlan(rebalance_id, len(plans), halted=False, block_reason=block, drift={})
|
||||
|
||||
self._x.ensure_one_way_mode()
|
||||
intended = dict(acct.positions)
|
||||
for p in plans:
|
||||
self._x.set_leverage(p.symbol, cfg.leverage)
|
||||
arrival_mid = (p.price + p.abort_price) / 2.0
|
||||
# I4: use the true decision-time mid, NOT the maker/abort blend.
|
||||
arrival_mid = mids[p.symbol]
|
||||
# maker leg (post-only @ touch)
|
||||
mk = ChildOrder(p.symbol, p.side, p.qty, "LIMIT", p.price, p.reduce_only, True,
|
||||
client_id(rebalance_id, p.symbol, "maker"), "GTX")
|
||||
f = self._x.place(mk)
|
||||
if f.status != "FILLED" and cfg.maker_wait_s > 0:
|
||||
time.sleep(cfg.maker_wait_s)
|
||||
if f.status not in ("FILLED",):
|
||||
if f.status != "FILLED":
|
||||
if cfg.maker_wait_s > 0:
|
||||
time.sleep(cfg.maker_wait_s)
|
||||
self._x.cancel(p.symbol, mk.client_id)
|
||||
remaining = round(p.qty - f.qty, 12)
|
||||
if remaining > 0:
|
||||
ioc = ChildOrder(p.symbol, p.side, remaining, "LIMIT", p.abort_price, p.reduce_only, False,
|
||||
client_id(rebalance_id, p.symbol, "ioc"), "IOC")
|
||||
f2 = self._x.place(ioc)
|
||||
self._persist(rebalance_id, ioc, f2, arrival_mid)
|
||||
# C2: fetch AUTHORITATIVE post-cancel fill — the maker may have continued filling
|
||||
# during the sleep; using the stale place-response would size the IOC too large.
|
||||
f = self._x.order_status(p.symbol, mk.client_id)
|
||||
self._persist(rebalance_id, mk, f, arrival_mid)
|
||||
signed = (f.qty if p.side == "BUY" else -f.qty)
|
||||
intended[p.symbol] = intended.get(p.symbol, 0.0) + signed
|
||||
# C1: accumulate BOTH maker and IOC fills into `intended` so reconciliation sees the
|
||||
# full executed quantity and does not false-halt on the IOC leg.
|
||||
intended[p.symbol] = intended.get(p.symbol, 0.0) + (f.qty if p.side == "BUY" else -f.qty)
|
||||
remaining = round(p.qty - f.qty, 12)
|
||||
if remaining > 0:
|
||||
ioc = ChildOrder(p.symbol, p.side, remaining, "LIMIT", p.abort_price, p.reduce_only, False,
|
||||
client_id(rebalance_id, p.symbol, "ioc"), "IOC")
|
||||
f2 = self._x.place(ioc)
|
||||
if f2.qty > 0:
|
||||
self._persist(rebalance_id, ioc, f2, arrival_mid)
|
||||
intended[p.symbol] = intended.get(p.symbol, 0.0) + (f2.qty if p.side == "BUY" else -f2.qty)
|
||||
|
||||
actual = {pp.symbol: pp.qty for pp in self._x.positions()}
|
||||
drift = diff_positions(intended, actual, tol_frac=cfg.recon_tol_frac)
|
||||
|
||||
@@ -78,3 +78,4 @@ class CryptoExchange(Protocol):
|
||||
def ensure_one_way_mode(self) -> None: ...
|
||||
def place(self, order: ChildOrder) -> Fill: ...
|
||||
def cancel(self, symbol: str, client_id: str) -> None: ...
|
||||
def order_status(self, symbol: str, client_id: str) -> Fill: ...
|
||||
|
||||
@@ -25,6 +25,84 @@ class FakeExchange:
|
||||
self._pos[o.symbol] = self._pos.get(o.symbol, 0.0) + (o.qty if o.side == "BUY" else -o.qty)
|
||||
return Fill(o.client_id, o.symbol, o.side, o.qty, o.price, 0.0, "FILLED")
|
||||
def cancel(self, s, cid): pass
|
||||
def order_status(self, symbol: str, client_id: str) -> Fill:
|
||||
# Default: authoritative state matches the place-time fill (maker fully filled)
|
||||
for o in self.placed:
|
||||
if o.client_id == client_id:
|
||||
return Fill(client_id, symbol, o.side, o.qty, o.price, 0.0, "FILLED")
|
||||
return Fill(client_id, symbol, "", 0.0, 0.0, 0.0, "CANCELED")
|
||||
|
||||
|
||||
class FakeExchangePartialMaker(FakeExchange):
|
||||
"""Maker leg only partially fills at place time; order_status returns the same partial amount
|
||||
(i.e., the maker did NOT fill further during the sleep). IOC picks up the remainder."""
|
||||
|
||||
def __init__(self, maker_fill_frac: float = 0.4):
|
||||
super().__init__()
|
||||
self._maker_fill_frac = maker_fill_frac
|
||||
# track maker client_ids so we can return correct order_status
|
||||
self._maker_fills: dict[str, Fill] = {}
|
||||
|
||||
def place(self, o: ChildOrder) -> Fill:
|
||||
self.placed.append(o)
|
||||
if o.time_in_force == "GTX":
|
||||
# maker leg: only partially fills
|
||||
filled_qty = round(o.qty * self._maker_fill_frac, 12)
|
||||
fill = Fill(o.client_id, o.symbol, o.side, filled_qty, o.price, 0.0,
|
||||
"PARTIAL" if filled_qty > 0 else "CANCELED")
|
||||
self._maker_fills[o.client_id] = fill
|
||||
signed = filled_qty if o.side == "BUY" else -filled_qty
|
||||
self._pos[o.symbol] = self._pos.get(o.symbol, 0.0) + signed
|
||||
return fill
|
||||
else:
|
||||
# IOC leg: fills the remainder fully
|
||||
fill = Fill(o.client_id, o.symbol, o.side, o.qty, o.price, 0.0, "FILLED")
|
||||
signed = o.qty if o.side == "BUY" else -o.qty
|
||||
self._pos[o.symbol] = self._pos.get(o.symbol, 0.0) + signed
|
||||
return fill
|
||||
|
||||
def order_status(self, symbol: str, client_id: str) -> Fill:
|
||||
# Returns the authoritative post-cancel maker fill (same as place-time partial here)
|
||||
if client_id in self._maker_fills:
|
||||
return self._maker_fills[client_id]
|
||||
return Fill(client_id, symbol, "", 0.0, 0.0, 0.0, "CANCELED")
|
||||
|
||||
|
||||
class FakeExchangeMakerFillsDuringSleep(FakeExchange):
|
||||
"""Maker fills MORE during the sleep than at place time: place returns 40%, order_status returns 100%.
|
||||
The IOC remainder must be 0 — no over-fill."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._maker_fills: dict[str, tuple[Fill, float]] = {} # client_id -> (place_fill, full_qty)
|
||||
|
||||
def place(self, o: ChildOrder) -> Fill:
|
||||
self.placed.append(o)
|
||||
if o.time_in_force == "GTX":
|
||||
# place sees only 40% filled
|
||||
partial_qty = round(o.qty * 0.4, 12)
|
||||
fill = Fill(o.client_id, o.symbol, o.side, partial_qty, o.price, 0.0, "PARTIAL")
|
||||
self._maker_fills[o.client_id] = (fill, o.qty)
|
||||
signed = partial_qty if o.side == "BUY" else -partial_qty
|
||||
self._pos[o.symbol] = self._pos.get(o.symbol, 0.0) + signed
|
||||
return fill
|
||||
else:
|
||||
# IOC: should NOT be called if remainder == 0
|
||||
fill = Fill(o.client_id, o.symbol, o.side, o.qty, o.price, 0.0, "FILLED")
|
||||
signed = o.qty if o.side == "BUY" else -o.qty
|
||||
self._pos[o.symbol] = self._pos.get(o.symbol, 0.0) + signed
|
||||
return fill
|
||||
|
||||
def order_status(self, symbol: str, client_id: str) -> Fill:
|
||||
# Authoritative: maker fully filled during the sleep window
|
||||
if client_id in self._maker_fills:
|
||||
place_fill, full_qty = self._maker_fills[client_id]
|
||||
# Update position: add the remaining fill that happened during sleep
|
||||
extra = full_qty - place_fill.qty
|
||||
signed_extra = extra if place_fill.side == "BUY" else -extra
|
||||
self._pos[symbol] = self._pos.get(symbol, 0.0) + signed_extra
|
||||
return Fill(client_id, symbol, place_fill.side, full_qty, place_fill.price, 0.0, "FILLED")
|
||||
return Fill(client_id, symbol, "", 0.0, 0.0, 0.0, "CANCELED")
|
||||
|
||||
|
||||
def _cfg(**kw):
|
||||
@@ -59,3 +137,79 @@ def test_kill_switch_halts_before_placing() -> None:
|
||||
plan = svc.rebalance({"AAA": 0.5}, adv_usd={"AAA": 1e9}, edge_theta=1.0, kelly_fraction=1.0,
|
||||
rebalance_id="r1", execute=True)
|
||||
assert plan.halted is True and plan.block_reason == "kill_switch" and fx.placed == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IOC-path tests (maker -> partial -> cancel -> order_status -> IOC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ioc_fallback_sizes_remainder_and_reconciles_clean() -> None:
|
||||
"""C2+C1: maker fills 40% at place; order_status confirms 40%; IOC fills the remaining 60%.
|
||||
Final position == target, drift == {}, halted is False, BOTH legs persisted."""
|
||||
fx = FakeExchangePartialMaker(maker_fill_frac=0.4)
|
||||
store = ExecStore("sqlite://")
|
||||
svc = CryptoExecutionService(fx, store, _cfg(maker_wait_s=0.0))
|
||||
plan = svc.rebalance({"AAA": 0.5}, adv_usd={"AAA": 1e9},
|
||||
edge_theta=1.0, kelly_fraction=1.0, rebalance_id="r2", execute=True)
|
||||
|
||||
# Both maker (partial) and IOC legs must have been placed
|
||||
placed_tif = [o.time_in_force for o in fx.placed]
|
||||
assert "GTX" in placed_tif, "maker leg not placed"
|
||||
assert "IOC" in placed_tif, "IOC leg not placed"
|
||||
|
||||
# Two fills persisted (one per leg)
|
||||
fills = store.fills("r2")
|
||||
aaa_fills = [f for f in fills if f.symbol == "AAA"]
|
||||
assert len(aaa_fills) == 2, f"expected 2 fills for AAA, got {len(aaa_fills)}"
|
||||
|
||||
# Clean reconcile — no drift, no halt
|
||||
assert plan.drift == {}, f"unexpected drift: {plan.drift}"
|
||||
assert plan.halted is False
|
||||
|
||||
|
||||
def test_ioc_not_oversized_when_maker_fills_during_wait() -> None:
|
||||
"""C2: order_status returns 100% fill (maker filled during sleep after 40% at place).
|
||||
IOC remainder must be 0 — no over-fill, IOC must NOT be placed."""
|
||||
fx = FakeExchangeMakerFillsDuringSleep()
|
||||
store = ExecStore("sqlite://")
|
||||
svc = CryptoExecutionService(fx, store, _cfg(maker_wait_s=0.0))
|
||||
plan = svc.rebalance({"AAA": 0.5}, adv_usd={"AAA": 1e9},
|
||||
edge_theta=1.0, kelly_fraction=1.0, rebalance_id="r3", execute=True)
|
||||
|
||||
# Only the maker leg should have been placed — no IOC
|
||||
placed_tif = [o.time_in_force for o in fx.placed]
|
||||
assert "GTX" in placed_tif, "maker leg not placed"
|
||||
assert "IOC" not in placed_tif, "IOC must NOT be placed when maker fully filled during wait"
|
||||
|
||||
# One fill persisted (maker only)
|
||||
fills = store.fills("r3")
|
||||
aaa_fills = [f for f in fills if f.symbol == "AAA"]
|
||||
assert len(aaa_fills) == 1, f"expected 1 fill for AAA (maker only), got {len(aaa_fills)}"
|
||||
|
||||
# Clean reconcile
|
||||
assert plan.drift == {}, f"unexpected drift: {plan.drift}"
|
||||
assert plan.halted is False
|
||||
|
||||
|
||||
def test_zero_scale_holds() -> None:
|
||||
"""I5: edge_theta=0.0 -> gross_scaler==0 -> HOLD (no orders), block_reason set, nothing placed."""
|
||||
fx = FakeExchange()
|
||||
svc = CryptoExecutionService(fx, ExecStore("sqlite://"), _cfg())
|
||||
plan = svc.rebalance({"AAA": 0.5, "BBB": -0.5}, adv_usd={"AAA": 1e9, "BBB": 1e9},
|
||||
edge_theta=0.0, kelly_fraction=1.0, rebalance_id="r4", execute=True)
|
||||
assert plan.block_reason == "zero_gross_scale_hold"
|
||||
assert plan.n_orders == 0
|
||||
assert fx.placed == []
|
||||
assert plan.halted is False # hold is not a halt; no drift
|
||||
|
||||
|
||||
def test_dry_run_surfaces_gate_block_reason() -> None:
|
||||
"""I3: a tripped gate must appear in block_reason even on a dry-run (execute=False)."""
|
||||
fx = FakeExchange()
|
||||
svc = CryptoExecutionService(fx, ExecStore("sqlite://"), _cfg(kill_switch=True))
|
||||
plan = svc.rebalance({"AAA": 0.5}, adv_usd={"AAA": 1e9}, edge_theta=1.0, kelly_fraction=1.0,
|
||||
rebalance_id="r5", execute=False)
|
||||
# dry-run: nothing placed, but block_reason is exposed for monitoring
|
||||
assert fx.placed == []
|
||||
assert plan.block_reason == "kill_switch"
|
||||
assert plan.halted is False # dry-run never halts; it just reports
|
||||
|
||||
@@ -18,3 +18,16 @@ def test_drift_beyond_tolerance_flagged() -> None:
|
||||
def test_unexpected_position_flagged() -> None:
|
||||
drift = diff_positions(intended={}, actual={"Z": 3.0}, tol_frac=0.01)
|
||||
assert "Z" in drift
|
||||
|
||||
|
||||
def test_short_position_drift_flagged() -> None:
|
||||
"""A short position (negative qty) that drifts beyond tolerance must be flagged."""
|
||||
drift = diff_positions(intended={"S": -2.0}, actual={"S": -1.0}, tol_frac=0.01)
|
||||
assert "S" in drift
|
||||
assert abs(drift["S"] - 1.0) < 1e-9 # actual - intended = -1.0 - (-2.0) = +1.0
|
||||
|
||||
|
||||
def test_expected_position_fully_closed_is_clean() -> None:
|
||||
"""Intended 0 and actual 0 (position closed) must produce no drift entry."""
|
||||
drift = diff_positions(intended={"X": 0.0}, actual={}, tol_frac=0.01)
|
||||
assert drift == {}
|
||||
|
||||
@@ -14,3 +14,8 @@ def test_sell_below_mid_is_positive_cost() -> None:
|
||||
|
||||
def test_no_fill_is_zero() -> None:
|
||||
assert shortfall_bps("BUY", arrival_mid=100.0, fill_price=0.0) == 0.0
|
||||
|
||||
|
||||
def test_sell_above_mid_is_negative_shortfall() -> None:
|
||||
"""Selling above mid is a SAVING (negative cost) — maker rebate / price improvement."""
|
||||
assert abs(shortfall_bps("SELL", arrival_mid=100.0, fill_price=100.1) - (-10.0)) < 1e-6
|
||||
|
||||
Reference in New Issue
Block a user