Files
fxhnt/tests/integration/test_crypto_execution.py
jgrusewski abdc9b8dc8 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>
2026-06-14 17:29:37 +02:00

216 lines
11 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