diff --git a/src/fxhnt/adapters/exchange/binance_ccxt.py b/src/fxhnt/adapters/exchange/binance_ccxt.py index 9196c4f..1d02009 100644 --- a/src/fxhnt/adapters/exchange/binance_ccxt.py +++ b/src/fxhnt/adapters/exchange/binance_ccxt.py @@ -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) diff --git a/src/fxhnt/application/crypto_execution.py b/src/fxhnt/application/crypto_execution.py index 51b9886..5ef047d 100644 --- a/src/fxhnt/application/crypto_execution.py +++ b/src/fxhnt/application/crypto_execution.py @@ -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) diff --git a/src/fxhnt/ports/crypto_exchange.py b/src/fxhnt/ports/crypto_exchange.py index fa5025a..e0eb2eb 100644 --- a/src/fxhnt/ports/crypto_exchange.py +++ b/src/fxhnt/ports/crypto_exchange.py @@ -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: ... diff --git a/tests/integration/test_crypto_execution.py b/tests/integration/test_crypto_execution.py index 4e4281b..59314c9 100644 --- a/tests/integration/test_crypto_execution.py +++ b/tests/integration/test_crypto_execution.py @@ -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 diff --git a/tests/unit/test_reconciliation.py b/tests/unit/test_reconciliation.py index 68f23f8..0f8c6cb 100644 --- a/tests/unit/test_reconciliation.py +++ b/tests/unit/test_reconciliation.py @@ -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 == {} diff --git a/tests/unit/test_tca.py b/tests/unit/test_tca.py index 6ef2210..cf7e589 100644 --- a/tests/unit/test_tca.py +++ b/tests/unit/test_tca.py @@ -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