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:
jgrusewski
2026-06-14 17:29:37 +02:00
parent 64b0204703
commit abdc9b8dc8
6 changed files with 235 additions and 14 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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

View File

@@ -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 == {}

View File

@@ -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