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>
264 lines
13 KiB
Python
264 lines
13 KiB
Python
"""Orchestrator end-to-end with a FAKE exchange: targets -> gates -> router -> place -> reconcile -> persist.
|
|
Maker fills here (fake fills the maker leg), so no IOC needed; reconciliation is clean."""
|
|
from __future__ import annotations
|
|
|
|
from fxhnt.adapters.persistence.exec_store import ExecStore
|
|
from fxhnt.application.crypto_execution import CryptoExecutionService, ExecConfig
|
|
from fxhnt.ports.crypto_exchange import (
|
|
ChildOrder, CryptoAccount, Fill, OrderBook, PerpPosition, SymbolFilter,
|
|
)
|
|
|
|
|
|
class FakeExchange:
|
|
name = "fake"
|
|
def __init__(self):
|
|
self._pos: dict[str, float] = {}
|
|
self.placed: list[ChildOrder] = []
|
|
def exchange_info(self): return {s: SymbolFilter(s, 0.001, 0.1, 5.0) for s in ("AAA", "BBB")}
|
|
def order_book(self, s): return OrderBook(s, 100.0, 100.1, 100.0, 100.0) # deep book
|
|
def positions(self): return [PerpPosition(s, q, 100.0, 3) for s, q in self._pos.items() if q]
|
|
def account(self): return CryptoAccount(nlv=10_000.0, available_balance=10_000.0, positions=dict(self._pos))
|
|
def set_leverage(self, s, l): pass
|
|
def ensure_one_way_mode(self): pass
|
|
def place(self, o: ChildOrder) -> Fill:
|
|
self.placed.append(o)
|
|
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):
|
|
base = dict(testnet=True, allow_live=False, kill_switch=False, leverage=3, gross_cap=1.0,
|
|
per_symbol_cap=0.5, participation_rate=1.0, depth_cap=1.0, max_slippage_bps=25.0,
|
|
hysteresis=0.001, kelly_cap=0.25, maker_wait_s=0.0)
|
|
base.update(kw); return ExecConfig(**base)
|
|
|
|
|
|
def test_dry_run_places_nothing() -> None:
|
|
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=1.0, kelly_fraction=1.0, rebalance_id="r1", execute=False)
|
|
assert plan.n_orders > 0 and fx.placed == [] # planned, not placed
|
|
|
|
|
|
def test_execute_places_and_reconciles_clean() -> None:
|
|
fx = FakeExchange()
|
|
store = ExecStore("sqlite://")
|
|
svc = CryptoExecutionService(fx, store, _cfg())
|
|
plan = svc.rebalance({"AAA": 0.5, "BBB": -0.5}, adv_usd={"AAA": 1e9, "BBB": 1e9},
|
|
edge_theta=1.0, kelly_fraction=1.0, rebalance_id="r1", execute=True)
|
|
assert len(fx.placed) == 2 and plan.drift == {} # both legs placed, reconciliation clean
|
|
assert plan.halted is False
|
|
assert len(store.fills("r1")) == 2 # fills persisted
|
|
|
|
|
|
def test_kill_switch_halts_before_placing() -> None:
|
|
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="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
|
|
|
|
|
|
class FakeExchangeWithDriftedUntraded(FakeExchange):
|
|
"""FakeExchange that seeds a position in 'CCC' (not in target weights / exchange_info),
|
|
then returns a DIFFERENT qty from positions() to simulate external drift / partial liquidation."""
|
|
|
|
def __init__(self, seeded_qty: float, drifted_qty: float) -> None:
|
|
super().__init__()
|
|
# Pre-seed CCC so account() includes it in positions (feeds into `intended` via acct.positions)
|
|
self._pos["CCC"] = seeded_qty
|
|
self._drifted_qty = drifted_qty
|
|
|
|
def positions(self) -> list[PerpPosition]:
|
|
"""Post-trade reconcile call: return traded symbols at their real qty, but CCC at drifted_qty."""
|
|
result = []
|
|
for sym, qty in self._pos.items():
|
|
if sym == "CCC":
|
|
# Simulate external drift: exchange reports a different qty than expected
|
|
if self._drifted_qty != 0.0:
|
|
result.append(PerpPosition("CCC", self._drifted_qty, 100.0, 3))
|
|
# if drifted_qty is 0 we omit CCC entirely (position was liquidated externally)
|
|
elif qty:
|
|
result.append(PerpPosition(sym, qty, 100.0, 3))
|
|
return result
|
|
|
|
|
|
def test_reconcile_flags_unexpected_drift_on_untraded_symbol() -> None:
|
|
"""Fix 4: reconciliation must catch drift on symbols NOT traded in this cycle.
|
|
|
|
Scenario: the exchange holds 5.0 CCC (seeded pre-trade). CCC is NOT in target weights
|
|
and NOT in exchange_info, so the orchestrator never places an order for it.
|
|
Post-trade, positions() returns 2.0 CCC (external partial liquidation / drift).
|
|
Expected: plan.drift contains 'CCC' and plan.halted is True.
|
|
"""
|
|
seeded_qty = 5.0
|
|
drifted_qty = 2.0 # external partial liquidation: 3.0 units disappeared silently
|
|
|
|
fx = FakeExchangeWithDriftedUntraded(seeded_qty=seeded_qty, drifted_qty=drifted_qty)
|
|
svc = CryptoExecutionService(fx, ExecStore("sqlite://"), _cfg())
|
|
|
|
# Only trade AAA/BBB — CCC is absent from weights and from exchange_info
|
|
plan = svc.rebalance({"AAA": 0.5, "BBB": -0.5}, adv_usd={"AAA": 1e9, "BBB": 1e9},
|
|
edge_theta=1.0, kelly_fraction=1.0, rebalance_id="r6", execute=True)
|
|
|
|
assert "CCC" in plan.drift, (
|
|
f"CCC drift not detected; plan.drift={plan.drift}"
|
|
)
|
|
assert plan.halted is True, "unexpected drift on untraded symbol must halt"
|