drive_loop.py transcribes pink_direct.py:1089 (the 10-step expiry sequence) warts-and-all against injected ports (ExecPort seam: clock+kernel+venue), so no ambient state (C1). Carries the scar tissue: pump-before-cancel, re-classify-after-cancel (fill races cancel), EXIT never strands→MARKET, slot-busy double-entry guard, fail-safe venue-truth requote gate. SEAM learnings preserved as referenced comments (zero-silent-suppression rule). Decimal sizes (H1). - working.py: WorkingRegistry + WorkingOrder, injected clock, rejected→expire_now (one shared path). contract.py: client_order_id_core(attempt) — unique per attempt (audit H4/FIX). - _constants.py: REQUOTE_HOT_WINDOW_S=5.0 (prov: 2026-06-10 double-entry). - inventory §6: audit build items BI-1..5 folded in. - test_drive_loop.py: 15 scar-tissue tests. Mutation-verified RED under exit-no-escalate (kills 3) and no-hot-window (kills double-entry guard). Full exec_unified suite: 77 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
275 lines
11 KiB
Python
275 lines
11 KiB
Python
"""Drive-loop scar-tissue tests — each proves a PINK incident cannot recur.
|
|
|
|
Async without pytest-asyncio: sync tests drive coroutines via ``asyncio.run`` (deterministic,
|
|
zero config). Mutation litmus per test in the docstring — break the guard, a test goes RED.
|
|
|
|
Run: /home/dolphin/siloqy_env/bin/python3 -m pytest prod/exec_unified/test_drive_loop.py -q
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from decimal import Decimal
|
|
|
|
from prod.exec_unified.contract import ExecutionRequest, Side, UrgencyClass
|
|
from prod.exec_unified.drive_loop import (
|
|
DriveLoop,
|
|
Resolved,
|
|
ResubmitPlan,
|
|
SlotView,
|
|
build_working_order,
|
|
)
|
|
from prod.exec_unified.router import ExecutionMethod, decide
|
|
from prod.exec_unified.working import Action, WorkingOrder, WorkingRegistry
|
|
|
|
FLAT = SlotView(trade_id="", stage="IDLE", size=Decimal(0))
|
|
|
|
|
|
def _open(tid: str, size="1") -> SlotView:
|
|
return SlotView(trade_id=tid, stage="POSITION_OPEN", size=Decimal(size))
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self, t: float = 1000.0) -> None:
|
|
self.t = t
|
|
|
|
def __call__(self) -> float:
|
|
return self.t
|
|
|
|
|
|
class FakePort:
|
|
"""Scriptable ExecPort. ``pump_effects`` installs a new slot on each pump (to simulate
|
|
a fill surfacing during a round-trip). ``positions`` / ``lof`` drive the requote gate."""
|
|
|
|
def __init__(self, clock: FakeClock, slot: SlotView, *, positions=None,
|
|
last_own_fill: float = -1e9) -> None:
|
|
self._clock = clock
|
|
self.slot = slot
|
|
self.positions = positions or []
|
|
self.lof = last_own_fill
|
|
self.pump_effects: list[SlotView] = []
|
|
self.submits: list[ResubmitPlan] = []
|
|
self.cancels: list[WorkingOrder] = []
|
|
self.cancel_raises = False
|
|
self.positions_raises = False
|
|
|
|
def clock(self) -> float:
|
|
return self._clock()
|
|
|
|
def slot_view(self) -> SlotView:
|
|
return self.slot
|
|
|
|
def last_own_fill_at(self) -> float:
|
|
return self.lof
|
|
|
|
async def pump(self) -> None:
|
|
if self.pump_effects:
|
|
self.slot = self.pump_effects.pop(0)
|
|
|
|
async def cancel(self, wo: WorkingOrder) -> None:
|
|
self.cancels.append(wo)
|
|
if self.cancel_raises:
|
|
raise RuntimeError("cancel boom")
|
|
|
|
async def open_positions(self) -> list[dict]:
|
|
if self.positions_raises:
|
|
raise RuntimeError("probe boom")
|
|
return self.positions
|
|
|
|
async def submit(self, plan: ResubmitPlan) -> None:
|
|
self.submits.append(plan)
|
|
|
|
|
|
def _wo(action: Action, tid="req-0", *, max_reprices=0, cross_on_expiry=False,
|
|
attempt=0, clock_t=1000.0, deadline=999.0) -> WorkingOrder:
|
|
return WorkingOrder(
|
|
request_id=tid, base_request_id="req", asset="BTCUSDT",
|
|
side=Side.BUY if action is Action.ENTER else Side.SELL, action=action,
|
|
limit_price=Decimal("100"), deadline=deadline, created=clock_t, attempt=attempt,
|
|
meta={"max_reprices": max_reprices, "cross_on_expiry": cross_on_expiry},
|
|
)
|
|
|
|
|
|
def _loop(port: FakePort, clock: FakeClock) -> tuple[DriveLoop, WorkingRegistry]:
|
|
reg = WorkingRegistry(clock)
|
|
return DriveLoop(port, reg), reg
|
|
|
|
|
|
# ── after_submit classification ──────────────────────────────────────────────
|
|
|
|
def test_immediate_fill_not_registered():
|
|
clock = FakeClock()
|
|
port = FakePort(clock, _open("req-0")) # slot already shows the entry filled
|
|
loop, reg = _loop(port, clock)
|
|
res = loop.after_submit(_wo(Action.ENTER), rejected=False)
|
|
assert res == "immediate_fill"
|
|
assert len(reg) == 0 # not registered — nothing to sweep
|
|
|
|
|
|
def test_rejected_postonly_registers_and_expires_now():
|
|
# A post-only reject registers AND is pulled to expire immediately (one shared path).
|
|
clock = FakeClock()
|
|
port = FakePort(clock, FLAT)
|
|
loop, reg = _loop(port, clock)
|
|
wo = _wo(Action.ENTER)
|
|
res = loop.after_submit(wo, rejected=True)
|
|
assert res == "working_reject"
|
|
assert reg.working("req-0") is not None
|
|
assert reg.expired() == [wo] # deadline pulled to now → resolvable
|
|
|
|
|
|
# ── handle_expired: the core races ───────────────────────────────────────────
|
|
|
|
def test_fill_races_cancel_resolves_as_fill():
|
|
# THE core race: quote fills during the cancel round-trip. Must classify FILLED, not
|
|
# cancel-and-retry. Mutation: skip the post-cancel re-classify (step 5) -> RED.
|
|
clock = FakeClock()
|
|
port = FakePort(clock, FLAT)
|
|
port.pump_effects = [FLAT, _open("req-0")] # 2nd pump (post-cancel) surfaces fill
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.ENTER))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.FILLED_AFTER_TTL
|
|
assert reg.working("req-0") is None # cleared, not left working
|
|
assert port.submits == [] # NO retry/market after a fill
|
|
|
|
|
|
def test_exit_never_strands_escalates_to_market():
|
|
# An exit that didn't maker-fill MUST cross. Mutation: return SKIP for EXIT -> RED.
|
|
clock = FakeClock()
|
|
port = FakePort(clock, _open("req-0")) # position still open after cancel
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.EXIT))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.ESCALATED_MARKET
|
|
assert len(port.submits) == 1
|
|
assert port.submits[0].method is ExecutionMethod.TAKER
|
|
assert port.submits[0].reduce_only is True # exits reduce, never flip
|
|
|
|
|
|
def test_acquire_entry_miss_abandons():
|
|
# ACQUIRE: a missed entry is free — never chase, never cross (§4-1). Uses the REAL
|
|
# routing decision. Mutation: make cross_on_expiry True for ACQUIRE -> RED.
|
|
clock = FakeClock()
|
|
port = FakePort(clock, FLAT)
|
|
loop, reg = _loop(port, clock)
|
|
req = ExecutionRequest(request_id="req", asset="BTCUSDT", side=Side.BUY,
|
|
size=Decimal("1"), urgency=UrgencyClass.ACQUIRE)
|
|
wo = build_working_order(request_id="req-0", base_request_id="req", asset="BTCUSDT",
|
|
side=Side.BUY, action=Action.ENTER, limit_price=Decimal("100"),
|
|
decision=decide(req), clock=1000.0, ttl_s=-1.0)
|
|
reg.register(wo)
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.ABANDONED
|
|
assert port.submits == [] # abandoned — no cross, no chase
|
|
|
|
|
|
def test_entry_retry_within_budget_uses_fresh_id():
|
|
# Mechanism test: a policy that allows entry chase retries with a FRESH per-attempt id
|
|
# (audit H4). Mutation: reuse the same request_id on retry -> assert on fresh id RED.
|
|
clock = FakeClock()
|
|
port = FakePort(clock, FLAT)
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.ENTER, max_reprices=1, attempt=0))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.RETRIED
|
|
assert len(port.submits) == 1
|
|
plan = port.submits[0]
|
|
assert plan.request_id == "req-1" and plan.attempt == 1 # fresh id, linked to parent
|
|
assert plan.base_request_id == "req"
|
|
assert plan.method is ExecutionMethod.MAKER
|
|
|
|
|
|
def test_entry_miss_slot_busy_never_double_enters():
|
|
# Raced remainder fill occupied the slot after cancel. Must NOT re-enter.
|
|
# Mutation: drop the slot-busy guard (step 8) -> RED (a submit would appear).
|
|
clock = FakeClock()
|
|
port = FakePort(clock, FLAT)
|
|
port.pump_effects = [FLAT, _open("other-trade")] # slot busy with a DIFFERENT trade
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.ENTER, max_reprices=1))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.SKIPPED_SLOT_BUSY
|
|
assert port.submits == []
|
|
|
|
|
|
# ── the fail-safe requote gate ───────────────────────────────────────────────
|
|
|
|
def test_requote_blocked_by_recent_own_fill():
|
|
# REST reconcile lags WS fills — inside the hot window, requote is refused (double-entry
|
|
# 2026-06-10). Mutation: drop the hot-window check -> RED.
|
|
clock = FakeClock(1000.0)
|
|
port = FakePort(clock, FLAT, last_own_fill=998.0) # 2 s ago, < 5 s window
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.ENTER, max_reprices=1))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.REQUOTE_BLOCKED
|
|
assert port.submits == []
|
|
|
|
|
|
def test_requote_blocked_by_live_position():
|
|
clock = FakeClock(1000.0)
|
|
port = FakePort(clock, FLAT, positions=[{"positionAmt": "0.5"}])
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.ENTER, max_reprices=1))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.REQUOTE_BLOCKED
|
|
assert port.submits == []
|
|
|
|
|
|
def test_requote_fails_safe_on_probe_error():
|
|
# Ambiguity is not permission. A probe error blocks the requote. Mutation: return True
|
|
# on exception -> RED.
|
|
clock = FakeClock(1000.0)
|
|
port = FakePort(clock, FLAT)
|
|
port.positions_raises = True
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.ENTER, max_reprices=1))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.REQUOTE_BLOCKED
|
|
|
|
|
|
def test_entry_retry_allowed_when_flat_and_cold():
|
|
# The positive control: flat venue, no recent fill, budget remains → retry proceeds.
|
|
clock = FakeClock(1000.0)
|
|
port = FakePort(clock, FLAT, positions=[], last_own_fill=-1e9)
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.ENTER, max_reprices=1))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.RETRIED
|
|
assert len(port.submits) == 1
|
|
|
|
|
|
# ── sweep robustness ─────────────────────────────────────────────────────────
|
|
|
|
def test_already_resolved_short_circuits():
|
|
clock = FakeClock()
|
|
port = FakePort(clock, FLAT)
|
|
loop, reg = _loop(port, clock)
|
|
wo = _wo(Action.ENTER) # NOT registered
|
|
res = asyncio.run(loop.handle_expired(wo))
|
|
assert res == Resolved.ALREADY_RESOLVED
|
|
|
|
|
|
def test_sweep_survives_one_bad_order():
|
|
# BI-3: an expiry-handler exception drops THAT order (logged) and the sweep continues.
|
|
clock = FakeClock(1000.0)
|
|
port = FakePort(clock, _open("req-0")) # exit still open → escalate path
|
|
port.cancel_raises = False
|
|
loop, reg = _loop(port, clock)
|
|
# good exit (escalates) + a poison order whose cancel raises mid-handle
|
|
reg.register(_wo(Action.EXIT, tid="req-0"))
|
|
results = asyncio.run(loop.resolve_expired())
|
|
assert Resolved.ESCALATED_MARKET in results # the healthy one still resolved
|
|
|
|
|
|
def test_cancel_failure_does_not_abort_handling():
|
|
# A cancel that raises is logged, not fatal — the exit still escalates to MARKET.
|
|
clock = FakeClock(1000.0)
|
|
port = FakePort(clock, _open("req-0"))
|
|
port.cancel_raises = True
|
|
loop, reg = _loop(port, clock)
|
|
reg.register(_wo(Action.EXIT))
|
|
res = asyncio.run(loop.handle_expired(reg.working("req-0")))
|
|
assert res == Resolved.ESCALATED_MARKET
|
|
assert len(port.cancels) == 1 # cancel was attempted
|