Files
fxhnt/tests/integration/test_crypto_execution.py
jgrusewski 124edda582 fix(exec): stable data-derived rebalance_id + rate-limit signal + cover/drift invariant tests
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>
2026-06-14 17:39:29 +02:00

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"