From 42531a8265ba7ac69b0ec295f6657921caa55e1c Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 22 Oct 2026 14:11:44 +0100 Subject: [PATCH 1/4] Clarify managed-order execution names and routing stages Rename risk and child-order helpers, decompose routed replanning, and document current lifecycle behavior and proposed cancellation work. Verified 289 offline tests passed; 23 skipped. --- docs/engine.md | 2 +- examples/verify_kalshi_stop_fill.py | 12 +- examples/verify_kalshi_stop_limit.py | 4 +- examples/verify_polymarket_us_stop_fill.py | 14 +- synpath/engine/__init__.py | 4 +- synpath/engine/__main__.py | 2 +- synpath/engine/engine.py | 158 +++++++------ synpath/engine/eod.py | 4 +- synpath/engine/feeds.py | 4 +- synpath/engine/orders/__init__.py | 4 +- synpath/engine/orders/base.py | 57 +++-- synpath/engine/orders/iceberg.py | 2 +- synpath/engine/orders/lifecycle/README.md | 6 +- .../engine/orders/lifecycle/managed-states.md | 57 +++++ .../orders/lifecycle/routed-lifecycle-plan.md | 102 +++++++++ synpath/engine/orders/lifecycle/routed.md | 52 +++++ synpath/engine/orders/manager.py | 39 +++- synpath/engine/orders/oco.py | 4 +- synpath/engine/orders/peg.py | 2 +- synpath/engine/orders/routed.py | 212 ++++++++++++------ synpath/engine/orders/stop.py | 4 +- synpath/engine/orders/taker.py | 4 +- synpath/engine/orders/twap.py | 2 +- synpath/engine/risk.py | 24 +- synpath/server/trading.py | 20 +- tests/test_cancel_after_fill.py | 4 +- tests/test_engine.py | 4 +- tests/test_engine_orders.py | 126 +++++------ tests/test_engine_orders_demo.py | 12 +- tests/test_feeds.py | 8 +- tests/test_order_storage.py | 2 +- tests/test_polymarket_stop_lifecycle.py | 8 +- tests/test_postgres_storage.py | 6 +- tests/test_risk_duplicates.py | 6 +- tests/test_routed_limit.py | 24 +- tests/test_routed_recovery.py | 16 +- tests/test_serve_orders.py | 8 +- tests/test_server_trading.py | 2 +- tests/test_stop_lifecycle.py | 66 +++--- 39 files changed, 725 insertions(+), 362 deletions(-) create mode 100644 synpath/engine/orders/lifecycle/managed-states.md create mode 100644 synpath/engine/orders/lifecycle/routed-lifecycle-plan.md create mode 100644 synpath/engine/orders/lifecycle/routed.md diff --git a/docs/engine.md b/docs/engine.md index 65b3e62..82ed0ef 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -219,7 +219,7 @@ parent = await engine.submit(OrderRequest( market_id=bucket.market_id, side=Side.BUY, quantity=Decimal("500"), type="market", price=Decimal("0.42"), book="alpha", params={"min_stay_s": 5, "max_rounds": 20}, )) -engine.orders.get(parent.id).report() # filled, average, per venue, why it stopped +engine.managed_order_controller.get(parent.id).report() # filled, average, per venue, why it stopped await engine.bucket_position(bucket.id) # the ledger's positions, netted in bucket terms ``` diff --git a/examples/verify_kalshi_stop_fill.py b/examples/verify_kalshi_stop_fill.py index d7a47ef..5f70c57 100644 --- a/examples/verify_kalshi_stop_fill.py +++ b/examples/verify_kalshi_stop_fill.py @@ -188,7 +188,7 @@ async def verify(adapter, guard, directory, market, initial_book, trigger, limit parent = await engine.submit(request) parent_id = parent.id await engine.on_book({"market_id": market_id}) - if engine.orders.get(parent_id).state != "waiting" or guard.sent: + if engine.managed_order_controller.get(parent_id).state != "waiting" or guard.sent: raise RuntimeError("stop was not below its trigger when armed") await engine.stop() engine = engine_for(adapter, directory / "orders.db") @@ -198,12 +198,12 @@ async def verify(adapter, guard, directory, market, initial_book, trigger, limit report["waiting_restart_verified"] = True guard.stage = "entry" deadline = time.monotonic() + wait_s - while engine.orders.get(parent_id).state == "waiting" and time.monotonic() < deadline: + while engine.managed_order_controller.get(parent_id).state == "waiting" and time.monotonic() < deadline: book = await book_for(adapter, guard.ticker) engine.set_book(market_id, book) await engine.on_book({"market_id": market_id}) - await engine.orders.on_timer() - parent = engine.orders.get(parent_id) + await engine.managed_order_controller.on_timer() + parent = engine.managed_order_controller.get(parent_id) if parent.state != "waiting": report["observed_trigger_ask"] = str(book.best_ask) break @@ -218,7 +218,7 @@ async def verify(adapter, guard, directory, market, initial_book, trigger, limit return report await engine.on_order(entry) report.update(entry_status=entry.status.value, entry_filled=str(entry.filled), entry_average_price=str(entry.average_price), - entry_cost=str(entry.cost), entry_fee=str(entry.fee), parent_status=engine.orders.get(parent_id).as_order().status.value) + entry_cost=str(entry.cost), entry_fee=str(entry.fee), parent_status=engine.managed_order_controller.get(parent_id).as_order().status.value) fills = await adapter.fetch_my_trades(market_id=market_id, order_id=entry.id) report["entry_fill_records"] = [{"quantity": str(f.quantity), "price": str(f.price), "fee": str(f.fee)} for f in fills] if entry.filled <= 0: @@ -245,7 +245,7 @@ async def verify(adapter, guard, directory, market, initial_book, trigger, limit finally: guard.stage = "blocked" try: - if parent_id and engine.orders.get(parent_id) and engine.orders.get(parent_id).state == "waiting": + if parent_id and engine.managed_order_controller.get(parent_id) and engine.managed_order_controller.get(parent_id).state == "waiting": await engine.cancel(parent_id) # The exit is IOC and already terminal; the entry may rest. Read back and cancel only # these keys even after a failed/ambiguous response. Never resend. diff --git a/examples/verify_kalshi_stop_limit.py b/examples/verify_kalshi_stop_limit.py index 4f6a515..a54e8c3 100644 --- a/examples/verify_kalshi_stop_limit.py +++ b/examples/verify_kalshi_stop_limit.py @@ -180,7 +180,7 @@ async def verify(adapter: KalshiTrading, guard: OneOrderGuard, directory: Path) await engine.on_book({"market_id": MARKET}) finally: guard.enabled = False - managed = engine.orders.get(parent_id) + managed = engine.managed_order_controller.get(parent_id) if managed.state != "working" or len(managed.children) != 1 or guard.sent is None: raise RuntimeError("trigger did not create exactly one child") child = managed.children[0] @@ -194,7 +194,7 @@ async def verify(adapter: KalshiTrading, guard: OneOrderGuard, directory: Path) finally: guard.enabled = False try: - managed = engine.orders.get(parent_id) if parent_id else None + managed = engine.managed_order_controller.get(parent_id) if parent_id else None if managed is not None and managed.live and managed.children: guard.cancel_ids.update(c.order_id for c in managed.children) try: diff --git a/examples/verify_polymarket_us_stop_fill.py b/examples/verify_polymarket_us_stop_fill.py index e37897d..3598a62 100644 --- a/examples/verify_polymarket_us_stop_fill.py +++ b/examples/verify_polymarket_us_stop_fill.py @@ -184,15 +184,15 @@ async def verify(adapter, guard, directory, rules, initial, trigger, *, wait_s=2 # persists waiting before the next actual venue book observation. if not initially_eligible: await engine.on_book({"market_id": market_id}) - assert engine.orders.get(parent_id).state == "waiting" and not guard.sent + assert engine.managed_order_controller.get(parent_id).state == "waiting" and not guard.sent guard.stage = "entry" deadline = time.monotonic() + wait_s - while engine.orders.get(parent_id).state == "waiting" and time.monotonic() < deadline: + while engine.managed_order_controller.get(parent_id).state == "waiting" and time.monotonic() < deadline: book = await book_for(adapter, guard.slug) engine.set_book(market_id, book) await engine.on_book({"market_id": market_id}) - await engine.orders.on_timer() - if engine.orders.get(parent_id).state != "waiting": + await engine.managed_order_controller.on_timer() + if engine.managed_order_controller.get(parent_id).state != "waiting": report["observed_trigger_ask"] = str(book.best_ask) break await asyncio.sleep(poll_s) @@ -202,7 +202,7 @@ async def verify(adapter, guard, directory, rules, initial, trigger, *, wait_s=2 if "entry" in guard.sent: raise RuntimeError("create response unresolved; do not resend or infer acceptance") await engine.cancel(parent_id) - managed = engine.orders.get(parent_id) + managed = engine.managed_order_controller.get(parent_id) report["outcome"] = ("triggered but local dispatch failed" if managed.state == "rejected" else "no threshold crossing; no venue order") report["parent_status"] = managed.as_order().status.value @@ -211,7 +211,7 @@ async def verify(adapter, guard, directory, rules, initial, trigger, *, wait_s=2 entry = await terminal_order(adapter, oid, guard) await engine.on_order(entry) report.update(entry_status=entry.status.value, entry_filled=str(entry.filled), entry_average_price=str(entry.average_price), - entry_fee=str(entry.fee), parent_status=engine.orders.get(parent_id).as_order().status.value) + entry_fee=str(entry.fee), parent_status=engine.managed_order_controller.get(parent_id).as_order().status.value) if entry.filled <= 0: report["outcome"] = "triggered without a fill" return report @@ -246,7 +246,7 @@ async def verify(adapter, guard, directory, rules, initial, trigger, *, wait_s=2 finally: guard.stage = "blocked" try: - if parent_id and engine.orders.get(parent_id) and engine.orders.get(parent_id).state == "waiting": + if parent_id and engine.managed_order_controller.get(parent_id) and engine.managed_order_controller.get(parent_id).state == "waiting": await engine.cancel(parent_id) for stage, oid in guard.ids.items(): order = await terminal_order(adapter, oid, guard) diff --git a/synpath/engine/__init__.py b/synpath/engine/__init__.py index 6a93402..d809382 100644 --- a/synpath/engine/__init__.py +++ b/synpath/engine/__init__.py @@ -23,7 +23,7 @@ from .journal import Intent, IntentState, Journal, JournalEvent, LeaseLost from .storage import JournalStore, create_journal from .ledger import Ledger, PositionState, Rollup -from .orders import ManagedOrder, ManagedOrders +from .orders import ManagedOrder, ManagedOrderController from .risk import Decision, KillSwitch, RiskConfig, RiskEngine __all__ = [ @@ -32,5 +32,5 @@ "EventBus", "EngineEvent", "Subscription", "Ledger", "PositionState", "Rollup", "RiskConfig", "RiskEngine", "Decision", "KillSwitch", - "ManagedOrder", "ManagedOrders", + "ManagedOrder", "ManagedOrderController", ] diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index 75716bf..3bfd9ac 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -240,7 +240,7 @@ async def _status_loop(engine: Engine, interval: float) -> None: log.info( "synpath.engine: %s open orders, %s positions, realized %s, fees %s, events %s, halted=%s", len(engine.open_orders()), len(engine.ledger.open_positions()), total.realized, total.fees, - engine.bus.published, engine.risk.kill.engaged, + engine.bus.published, engine.order_risk_checks.kill.engaged, ) diff --git a/synpath/engine/engine.py b/synpath/engine/engine.py index e768b7b..5234098 100644 --- a/synpath/engine/engine.py +++ b/synpath/engine/engine.py @@ -59,7 +59,7 @@ from .storage import JournalStore, create_journal from .submissions import CancelUnconfirmed, SubmissionInDoubt, SubmissionPending, request_identity from .ledger import Ledger -from .orders import ManagedOrders, as_gtd +from .orders import ManagedOrderController, as_gtd from .orders.manager import REGISTRY from .risk import Decision, HaltPolicy, RiskConfig, RiskEngine @@ -187,7 +187,8 @@ def __init__( raise ValueError("execution engine and journal must use the same user_id") self.bus = EventBus(self.journal) self.ledger = Ledger() - self.risk = RiskEngine(risk or RiskConfig(), ledger=self.ledger, clock=clock) + # Holds the order risk rules, trading halt state and recent submission counters. + self.order_risk_checks = RiskEngine(risk or RiskConfig(), ledger=self.ledger, clock=clock) self.accounts: dict[str, Account] = dict(accounts or {}) for venue in self.adapters: # Every configured venue trades under some account; name the @@ -207,7 +208,8 @@ def __init__( self.event_of: dict[str, str] = {} """Market to event id, for the per-event cap.""" self._open_cache: dict[str, Order] = {} - self.orders = ManagedOrders(self) + # Creates, restores and advances engine-held orders, and links their venue children. + self.managed_order_controller = ManagedOrderController(self) self.books: dict[str, Any] = {} """The local books engine-held orders watch, fed by `on_book`.""" self.last_trade: dict[str, Decimal] = {} @@ -220,8 +222,8 @@ async def start(self) -> Recovery: await self.journal.open() if self.config.require_lease: await self.journal.acquire_lease(self.config.lease_name, ttl_ms=self.config.lease_ttl_ms) - version = await self.journal.save_config("risk", self.risk.config.model_dump(mode="json")) - self.risk.config_version = version + version = await self.journal.save_config("risk", self.order_risk_checks.config.model_dump(mode="json")) + self.order_risk_checks.config_version = version recovery = await self.recover() self.running = True self.started_ts = int(self.clock() * 1000) @@ -274,9 +276,12 @@ async def __aexit__(self, *exc: Any) -> None: # -- submitting ----------------------------------------------------------- - async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: Decimal | None = None, + async def submit(self, request: OrderRequest, *, venue: str | None = None, risk_reference_price: Decimal | None = None, precision: Precision | None = None) -> Order: - """Retry the same instruction, never repeat an uncertain venue send.""" + """Retry the same instruction, never repeat an uncertain venue send. + + `risk_reference_price` overrides the book midpoint for the limit-price risk check. + """ if request.user_id not in (None, self.config.user_id): raise BadRequest("user_id is assigned by the execution worker") if self.config.require_lease and not await self.journal.holds_lease(): @@ -284,10 +289,11 @@ async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: request = request.model_copy(update={"user_id": self.config.user_id}) supported = VENUE_ORDER_TYPES | {OrderType.STOP_MARKET, OrderType.STOP_LIMIT} if is_bucket_id(request.market_id) or request.type not in supported or request.params.get("walk"): - return await self._submit_once(request, venue=venue, mark=mark, precision=precision) + return await self._submit_once(request, venue=venue, risk_reference_price=risk_reference_price, precision=precision) venue = venue or self._venue_of(request) account = request.account or self.accounts.get(venue) or Account(venue=venue) - deliberate = request.client_order_id is not None + # Caller-assigned IDs bypass the accidental-duplicate check, not same-ID retry protection. + caller_supplied_order_id = request.client_order_id is not None request = request.model_copy(update={"account": account, "client_order_id": request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}"}) async with self._lock(f"submission:{request.client_order_id}"): @@ -316,7 +322,10 @@ async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: return result request = effective try: - return await self._submit_once(request, venue=venue, mark=mark, deliberate=deliberate, precision=precision) + return await self._submit_once( + request, venue=venue, risk_reference_price=risk_reference_price, + caller_supplied_order_id=caller_supplied_order_id, precision=precision, + ) except (RiskRejected, BadRequest, OrderRejected) as error: intent = await self.journal.intent(request.client_order_id) if intent is not None and intent.state == IntentState.PLANNED: @@ -327,13 +336,13 @@ async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: async def _existing_submission_result(self, submission: dict[str, Any]) -> Order | None: if submission["result_id"]: - parent = self.orders.get(submission["result_id"]) + parent = self.managed_order_controller.get(submission["result_id"]) if parent is None: stored = await self.journal.managed_one(submission["result_id"]) parent = self._rebuilt(*stored) if stored else None if parent is not None: - if parent.live and self.orders.get(parent.id) is None: - self.orders.remember(parent) + if parent.live and self.managed_order_controller.get(parent.id) is None: + self.managed_order_controller.remember(parent) return parent.as_order() result = await self.journal.order(submission["venue"], submission["result_id"]) if result is None: @@ -361,15 +370,20 @@ async def _existing_submission_result(self, submission: dict[str, Any]) -> Order client_order_id=submission["client_order_id"]) async def _submit_once(self, request: OrderRequest, *, venue: str | None = None, - mark: Decimal | None = None, deliberate: bool | None = None, + risk_reference_price: Decimal | None = None, caller_supplied_order_id: bool | None = None, precision: Precision | None = None) -> Order: """Risk, journal, send, record. Raises `RiskRejected` before anything - is sent, or the venue's own error after.""" + is sent, or the venue's own error after. + + `risk_reference_price` is the reference for the limit-price risk check. + `caller_supplied_order_id` skips the accidental-duplicate check when true; + when omitted, infer it before generating an order ID. + """ if request.user_id not in (None, self.config.user_id): raise BadRequest("user_id is assigned by the execution worker") request = request.model_copy(update={"user_id": self.config.user_id}) if is_bucket_id(request.market_id): - return await self._submit_bucket(request, mark=mark) + return await self._submit_bucket(request, risk_reference_price=risk_reference_price) venue = venue or self._venue_of(request) adapter = self._adapter(venue) account = request.account or self.accounts.get(venue) or Account(venue=venue) @@ -377,17 +391,21 @@ async def _submit_once(self, request: OrderRequest, *, venue: str | None = None, request = as_gtd(request, now_s=self.clock(), timezone_name=self.config.session_timezone, session_end=self.config.session_end) if request.type not in VENUE_ORDER_TYPES: - return await self.submit_managed(request, venue=venue, account=account, mark=mark) + return await self.submit_managed(request, venue=venue, account=account, risk_reference_price=risk_reference_price) if request.type == OrderType.MARKET and request.params.get("walk"): # An explicit request for the engine's book walk rather than the # adapter's single immediate limit. walked = request.model_copy(update={"params": {**request.params, "managed_kind": "market_engine"}}) - return await self.submit_managed(walked, venue=venue, account=account, mark=mark) - deliberate = request.client_order_id is not None if deliberate is None else deliberate + return await self.submit_managed(walked, venue=venue, account=account, risk_reference_price=risk_reference_price) + if caller_supplied_order_id is None: + caller_supplied_order_id = request.client_order_id is not None client_order_id = request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}" request = request.model_copy(update={"client_order_id": client_order_id, "account": account}) - decision = self.check(request, venue=venue, account=account, mark=mark, deliberate=deliberate) + decision = self.evaluate_order_risk( + request, venue=venue, account=account, risk_reference_price=risk_reference_price, + caller_supplied_order_id=caller_supplied_order_id, + ) if not decision.ok: await self.bus.publish("risk.rejected", { "rule": decision.rule, "message": decision.message, "client_order_id": client_order_id, @@ -399,7 +417,7 @@ async def _submit_once(self, request: OrderRequest, *, venue: str | None = None, await self.journal.record_intent(request, client_order_id=client_order_id, venue=venue, account=account) async with self._lock(f"{venue}:{client_order_id}"): await self.journal.mark_intent(client_order_id, IntentState.SENDING, bump_attempt=True) - self.risk.record_sent(request) + self.order_risk_checks.record_sent(request) try: order = (await adapter.create_order_with_precision(request, precision) if precision is not None else await adapter.create_order(request)) @@ -429,10 +447,11 @@ async def _submit_once(self, request: OrderRequest, *, venue: str | None = None, await self.bus.publish("order.accepted", order.model_dump(mode="json"), key=f"{venue}:{order.id}", persist=False) return order - async def _submit_bucket(self, request: OrderRequest, *, mark: Decimal | None) -> Order: + async def _submit_bucket(self, request: OrderRequest, *, risk_reference_price: Decimal | None) -> Order: """An order on a bucket is a market order the engine holds as a - `routed_limit` parent, with the bucket's definition on the request so - the parent is whole in the journal. Its `price` is the worst price the + `routed_limit` parent. The request stores the enabled-member bucket + configuration captured at submission; later edits to the saved bucket + do not change this running order. Its `price` is the worst price the caller accepts, in bucket terms, as on any market order here. The nominal venue is the first member's; the legs each carry their own.""" if request.type != OrderType.MARKET: @@ -446,8 +465,11 @@ async def _submit_bucket(self, request: OrderRequest, *, mark: Decimal | None) - bucket = Bucket.model_validate(row) if bucket.status != "active": raise BadRequest(f"{request.market_id}: the bucket is {bucket.status}") - # Only enabled members are routed to: the parent carries the bucket as - # it routes, so a member switched off is not a leg, a market or a venue of it. + # Capture the bucket configuration for this order, keeping only enabled members. + # The parent uses this configuration throughout execution, including after restart; + # later edits to the saved bucket do not change which members this order trades. + # Disabled members receive no child orders and require no venue adapters here. + # The saved bucket definition remains unchanged. bucket = bucket.routable() missing = bucket.venues() - set(self.adapters) if missing: @@ -456,7 +478,7 @@ async def _submit_bucket(self, request: OrderRequest, *, mark: Decimal | None) - account = request.account or self.accounts.get(nominal) or Account(venue=nominal) params = {**request.params, "managed_kind": "routed_limit", "bucket": bucket.model_dump(mode="json")} request = request.model_copy(update={"params": params, "account": account}) - return await self.submit_managed(request, venue=nominal, account=account, mark=mark) + return await self.submit_managed(request, venue=nominal, account=account, risk_reference_price=risk_reference_price) async def save_bucket(self, bucket: Bucket) -> Bucket: """Store or update a bucket definition in this engine's journal.""" @@ -465,10 +487,12 @@ async def save_bucket(self, bucket: Bucket) -> Bucket: return bucket async def submit_managed(self, request: OrderRequest, *, venue: str, account: Account, - mark: Decimal | None = None) -> Order: + risk_reference_price: Decimal | None = None) -> Order: """Accept an engine-held order: check it, journal it, start it.""" check_managed_fields(request, now_ms=int(self.clock() * 1000)) - decision = self.check(request, venue=venue, account=account, mark=mark) + decision = self.evaluate_order_risk( + request, venue=venue, account=account, risk_reference_price=risk_reference_price, + ) if not decision.ok: await self.bus.publish("risk.rejected", { "rule": decision.rule, "message": decision.message, "market_id": request.market_id, @@ -477,7 +501,7 @@ async def submit_managed(self, request: OrderRequest, *, venue: str, account: Ac raise RiskRejected(decision.message, rule=decision.rule) request = request.model_copy(update={"user_id": request.user_id or self.config.user_id, "client_order_id": request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}"}) - parent = await self.orders.create(request, venue=venue, account=account) + parent = await self.managed_order_controller.create(request, venue=venue, account=account) return parent.as_order() async def submit_child(self, parent: Any, request: OrderRequest, **kw: Any) -> Order: @@ -496,9 +520,9 @@ async def submit_child(self, parent: Any, request: OrderRequest, **kw: Any) -> O request = request.model_copy(update={"account": account}) if request.type in VENUE_ORDER_TYPES: order = await self.submit(request, venue=venue, **kw) - self.orders.adopt_child(parent.id, order.id, order.venue) + self.managed_order_controller.adopt_child(parent.id, order.id, order.venue) return order - leg = await self.orders.create(request, venue=venue, account=account, owner=parent) + leg = await self.managed_order_controller.create(request, venue=venue, account=account, owner=parent) return leg.as_order() @staticmethod @@ -509,8 +533,8 @@ def _child_venue(parent: Any, request: OrderRequest) -> str: return parent.venue async def cancel_child(self, parent: Any, order_id: str, venue: str | None = None) -> Order | None: - if order_id in self.orders.parents: - return (await self.orders.cancel(order_id, reason=f"cancelled by {parent.id}")).as_order() + if order_id in self.managed_order_controller.parents: + return (await self.managed_order_controller.cancel(order_id, reason=f"cancelled by {parent.id}")).as_order() child = parent.child_of(order_id, venue) venue = venue or (child.venue if child is not None else parent.venue) try: @@ -522,17 +546,21 @@ async def cancel_child(self, parent: Any, order_id: str, venue: str | None = Non except OrderNotFound: return None - def check(self, request: OrderRequest, *, venue: str, account: Account, mark: Decimal | None = None, - deliberate: bool = False) -> Decision: - """The risk decision on its own, for a caller that wants to ask first.""" + def evaluate_order_risk( + self, request: OrderRequest, *, venue: str, account: Account, + risk_reference_price: Decimal | None = None, + caller_supplied_order_id: bool = False, + ) -> Decision: + """Return whether an order passes the configured risk rules, without submitting it.""" market_id = request.market_id - if mark is None: - mark = self.book_mid(market_id) - return self.risk.check( + if risk_reference_price is None: + risk_reference_price = self.book_mid(market_id) + return self.order_risk_checks.evaluate_order_risk( request, venue=venue, account=account, open_orders=[o for o in self.open_orders(venue=venue)], - mark=mark, market_close_ts=self.market_close.get(market_id), event_id=self.event_of.get(market_id), - deliberate=deliberate, + risk_reference_price=risk_reference_price, market_close_ts=self.market_close.get(market_id), + event_id=self.event_of.get(market_id), + caller_supplied_order_id=caller_supplied_order_id, ) async def cancel(self, order_id: str, *, venue: str | None = None, market_id: str | None = None) -> Order: @@ -543,8 +571,8 @@ async def cancel(self, order_id: str, *, venue: str | None = None, market_id: st returned as it is, without a venue call; a venue that answers "not found" is asked for the order itself, so a fill that won the race is recorded as `closed` rather than overwritten as `canceled`.""" - if order_id in self.orders.parents: - parent = await self.orders.cancel(order_id) + if order_id in self.managed_order_controller.parents: + parent = await self.managed_order_controller.cancel(order_id) return parent.as_order() order = await self._known(order_id, venue) if order.is_terminal or order.held_by == HeldBy.ENGINE: @@ -574,7 +602,7 @@ async def cancel(self, order_id: str, *, venue: str | None = None, market_id: st await self.journal.upsert_order(result, event="order.canceled") self._remember(result) await self.journal.mark_intent(key, IntentState.SETTLED, order_id=order_id) - await self.orders.on_order(result) + await self.managed_order_controller.on_order(result) await self.bus.publish("order.canceled", result.model_dump(mode="json"), key=f"{venue}:{order_id}", persist=False) return result @@ -619,7 +647,7 @@ async def edit(self, request: EditRequest, *, venue: str | None = None) -> Order client_order_id=key, ) account = order.account or self.accounts.get(venue) or Account(venue=venue) - decision = self.check(intent_request, venue=venue, account=account) + decision = self.evaluate_order_risk(intent_request, venue=venue, account=account) if not decision.ok: await self.bus.publish("risk.rejected", { "rule": decision.rule, "message": decision.message, "order_id": order.id, "operation": "edit", @@ -634,7 +662,7 @@ async def edit(self, request: EditRequest, *, venue: str | None = None) -> Order await self.journal.upsert_order(result, event="order.edited") self._remember(result) await self.journal.mark_intent(key, IntentState.SETTLED, order_id=result.id) - await self.orders.on_order(result) + await self.managed_order_controller.on_order(result) await self.bus.publish("order.edited", result.model_dump(mode="json"), key=f"{venue}:{result.id}", persist=False) return result @@ -643,7 +671,7 @@ async def edit(self, request: EditRequest, *, venue: str | None = None) -> Order async def halt(self, reason: str, *, scope: str = "*", policy: HaltPolicy | None = None, rearm_after_s: float | None = None) -> dict[str, Any]: """Stop trading. Returns what the policy did, per venue.""" policy = policy or self.config.halt_policy - self.risk.kill.engage(reason=reason, scope=scope, policy=policy, rearm_after_s=rearm_after_s) + self.order_risk_checks.kill.engage(reason=reason, scope=scope, policy=policy, rearm_after_s=rearm_after_s) result: dict[str, Any] = {"policy": policy, "scope": scope, "reason": reason, "canceled": {}, "remaining": {}} if policy == "cancel": for venue, adapter in self.adapters.items(): @@ -663,7 +691,7 @@ async def halt(self, reason: str, *, scope: str = "*", policy: HaltPolicy | None canceled = order.model_copy(update={"status": OrderStatus.CANCELED}) await self.journal.upsert_order(canceled, event="order.canceled") self._remember(canceled) - result["managed"] = await self.orders.on_halt(reason, scope=scope) + result["managed"] = await self.managed_order_controller.on_halt(reason, scope=scope) await self.bus.publish("engine.halted", result) return result @@ -691,21 +719,21 @@ async def _finish_cancelling(self, venue: str, adapter: TradingExchange, *, roun return -1 async def resume(self, *, scope: str | None = None) -> None: - self.risk.kill.release(scope=scope) + self.order_risk_checks.kill.release(scope=scope) await self.bus.publish("engine.resumed", {"scope": scope or "*"}) async def pause_book(self, book: str, *, paused: bool = True) -> None: """Stop one strategy without stopping the engine.""" - books = set(self.risk.config.paused_books) + books = set(self.order_risk_checks.config.paused_books) books.add(book) if paused else books.discard(book) - self.risk.config = self.risk.config.model_copy(update={"paused_books": sorted(books)}) - self.risk.config_version = await self.journal.save_config("risk", self.risk.config.model_dump(mode="json")) + self.order_risk_checks.config = self.order_risk_checks.config.model_copy(update={"paused_books": sorted(books)}) + self.order_risk_checks.config_version = await self.journal.save_config("risk", self.order_risk_checks.config.model_dump(mode="json")) await self.bus.publish("engine.book_paused" if paused else "engine.book_resumed", {"book": book}) async def set_risk(self, config: RiskConfig, *, author: str | None = None) -> int: """Replace the rules; the new version is journaled and returned.""" version = await self.journal.save_config("risk", config.model_dump(mode="json"), author=author) - self.risk.configure(config, version=version) + self.order_risk_checks.configure(config, version=version) await self.bus.publish("risk.configured", {"version": version, "author": author}) return version @@ -717,7 +745,7 @@ async def recover(self) -> Recovery: for fill in await self.journal.fills(): self.ledger.apply_fill(fill) recovery.fills_replayed += 1 - self.risk.roll_day() + self.order_risk_checks.roll_day() for intent in await self.journal.in_doubt(): recovery.in_doubt += 1 outcome = await self._resolve(intent) @@ -729,11 +757,11 @@ async def recover(self) -> Recovery: else: # Pending or unreachable: still in doubt, and reported as such. recovery.unresolved += 1 - recovery.managed = await self.orders.restore() + recovery.managed = await self.managed_order_controller.restore() # After the in-doubt intents, so an order adopted a moment ago is in # the cache the risk rules count against. recovery.orders_open = len(await self.refresh_open_orders()) - await self.orders.resume_restored() + await self.managed_order_controller.resume_restored() return recovery async def sweep(self) -> list[dict[str, Any]]: @@ -830,7 +858,7 @@ async def on_order(self, order: Order) -> None: self._remember(order) await self.bus.publish(f"order.{order.status.value}", order.model_dump(mode="json"), key=f"{order.venue}:{order.id}", persist=False) - await self.orders.on_order(order) + await self.managed_order_controller.on_order(order) async def on_fill(self, fill: Fill) -> None: """Record a fill once, book it in the ledger, publish it. A fill of a @@ -858,7 +886,7 @@ async def on_book(self, event: Any) -> None: book = self.books.get(market_id) if book is not None: self.books[market_id] = book - await self.orders.on_book(market_id) + await self.managed_order_controller.on_book(market_id) def set_book(self, market_id: str, book: Any) -> None: """Hand the engine a `LocalBook` (or anything with `best_bid`, @@ -880,7 +908,7 @@ async def on_trade(self, event: Any) -> None: return self.last_trade_timestamps[market_id] = timestamp self.last_trade[market_id] = Decimal(str(price)) - await self.orders.on_trade(market_id, Decimal(str(price)), Decimal(str(quantity))) + await self.managed_order_controller.on_trade(market_id, Decimal(str(price)), Decimal(str(quantity))) async def on_settlement(self, settlement: Settlement) -> None: realized = self.ledger.apply_settlement(settlement) @@ -941,14 +969,14 @@ async def bucket_orders(self, bucket_id: str) -> list[Any]: holds is rebuilt from its journal snapshot, read-only.""" out = [] for kind, snapshot in await self.journal.managed_on(f"bucket:{bucket_id}"): - parent = self.orders.get(snapshot["id"]) or self._rebuilt(kind, snapshot) + parent = self.managed_order_controller.get(snapshot["id"]) or self._rebuilt(kind, snapshot) if parent is not None: out.append(parent) return out async def bucket_order(self, parent_id: str) -> Any | None: """One order on a bucket, by its id, or None if the id is not one.""" - parent = self.orders.get(parent_id) + parent = self.managed_order_controller.get(parent_id) if parent is None: found = await self.journal.managed_one(parent_id) parent = self._rebuilt(*found) if found else None @@ -1029,12 +1057,12 @@ async def _dispatch_loop(self) -> None: while self.running: parent_id = await self._dispatch_queue.get() self._queued_parents.discard(parent_id) - parent = self.orders.get(parent_id) + parent = self.managed_order_controller.get(parent_id) if parent is None or not parent.live: continue self.dispatch_parent = parent_id try: - await self.orders.run(parent, lambda ctx: parent.on_timer(ctx)) + await self.managed_order_controller.run(parent, lambda ctx: parent.on_timer(ctx)) finally: self.dispatch_parent = None finally: @@ -1045,7 +1073,7 @@ async def _managed_loop(self) -> None: while self.running: await asyncio.sleep(self.config.managed_tick_s) try: - await self.orders.on_timer() + await self.managed_order_controller.on_timer() except Exception: log.exception("synpath.engine: the managed-order timer failed") diff --git a/synpath/engine/eod.py b/synpath/engine/eod.py index 69e59b3..fea87e2 100644 --- a/synpath/engine/eod.py +++ b/synpath/engine/eod.py @@ -78,7 +78,7 @@ async def run(self, *, date: str | None = None, book_settlements: bool = True) - if book_settlements: report.settlements, report.settled_realized = await self._settle() - since = engine.risk.day_start_ms + since = engine.order_risk_checks.day_start_ms for fill in await engine.journal.fills(since_ts=since): report.fills += 1 report.volume += fill.quantity @@ -106,7 +106,7 @@ async def run(self, *, date: str | None = None, book_settlements: bool = True) - report.ended_ms = int(engine.clock() * 1000) await engine.bus.publish("eod.report", report.summary(), key=day) - engine.risk.roll_day(realized_now=total.realized) + engine.order_risk_checks.roll_day(realized_now=total.realized) await engine.journal.set_cursor("eod:last", day) return report diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index e55eff3..1761520 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -242,7 +242,7 @@ def wanted(self) -> dict[str, set[str]]: the engine can mark it from the book, `Engine.exit_price`), by venue, on venues with a market stream.""" out: dict[str, set[str]] = {} - watched = [market_id for market_id in list(self.engine.orders.by_market) if self.engine.orders.watching(market_id)] + watched = [market_id for market_id in list(self.engine.managed_order_controller.by_market) if self.engine.managed_order_controller.watching(market_id)] held = [state.market_id for state in self.engine.ledger.open_positions()] for market_id in watched + held: venue, _ = ids.split(market_id) @@ -271,7 +271,7 @@ async def sync(self) -> int: # The venue sends no fresh snapshot for a market the # connection already carried (the screen's, say): the # parents read the book they were just handed, now. - await self.engine.orders.on_book(market_id) + await self.engine.managed_order_controller.on_book(market_id) added += len(new) return added diff --git a/synpath/engine/orders/__init__.py b/synpath/engine/orders/__init__.py index b790adf..8b429af 100644 --- a/synpath/engine/orders/__init__.py +++ b/synpath/engine/orders/__init__.py @@ -27,7 +27,7 @@ from .base import Child, Context, ManagedOrder, State from .day import as_gtd, session_expiry from .iceberg import Iceberg -from .manager import REGISTRY, ManagedOrders, register +from .manager import REGISTRY, ManagedOrderController, register from .oco import OCO, Bracket from .peg import Peg from .routed import RoutedLimit @@ -36,7 +36,7 @@ from .twap import TWAP __all__ = [ - "ManagedOrder", "ManagedOrders", "Context", "Child", "State", "register", "REGISTRY", + "ManagedOrder", "ManagedOrderController", "Context", "Child", "State", "register", "REGISTRY", "StopMarket", "StopLimit", "TrailingStop", "Iceberg", "OCO", "Bracket", "TWAP", "Peg", "MarketOrder", "SmartTaker", "as_gtd", "session_expiry", ] diff --git a/synpath/engine/orders/base.py b/synpath/engine/orders/base.py index 49bc6cd..cdec96e 100644 --- a/synpath/engine/orders/base.py +++ b/synpath/engine/orders/base.py @@ -20,7 +20,7 @@ of which can be refused on its own. **Events arrive, the parent decides.** `on_book`, `on_trade`, `on_fill`, -`on_child`, `on_timer` and `on_halt` are the only ways in. A type that needs +`on_child_order_updated`, `on_timer` and `on_halt` are the only ways in. A type that needs the touch reads it from the book the engine keeps; one that needs a clock gets `on_timer` about once a second, which is also what makes a restart harmless: the timer does not care how long it has been away. @@ -214,7 +214,7 @@ async def publish(self, kind: str, payload: dict[str, Any] | None = None) -> Non await self.engine.bus.publish(kind, {"parent_id": self.parent.id, **(payload or {})}, key=self.parent.id) async def save(self) -> None: - await self.engine.orders.save(self.parent) + await self.engine.managed_order_controller.save(self.parent) class ManagedOrder: @@ -311,8 +311,13 @@ async def on_trade(self, ctx: Context, market_id: str, price: Decimal, quantity: async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: """One of this parent's children filled, wholly or in part.""" - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: - """A child's status changed (cancelled, rejected, expired, closed).""" + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: + """React to a child order update after its local record has been updated. + + `order` is the venue result; `child` is this parent's tracked child. + Implementations decide whether that change means waiting, replanning + or finishing the parent. This callback does not fetch the venue order. + """ async def on_timer(self, ctx: Context) -> None: """About once a second, whatever else happened.""" @@ -333,7 +338,7 @@ async def cancel(self, ctx: Context, reason: str = "cancelled", *, end: State = return self.state = "cancelling" await ctx.save() - await self.pull_children(ctx) + await self.cancel_live_children_without_waiting(ctx) if not self.live: # A child's final venue record, read while pulling it, ended the # parent already (a fill that won the race closes it). @@ -352,22 +357,31 @@ async def expire(self, ctx: Context) -> None: is tried again.""" await self.cancel(ctx, "expired", end="expired") - async def pull_children(self, ctx: Context) -> None: - """Cancel every live child. + async def cancel_live_children_without_waiting(self, ctx: Context) -> None: + """Request cancellation of each live child once and record each response. - A successful request that still reports a resting order is an - acknowledgement, not a terminal result. Keep the child live and the - parent pending until a later observation confirms cancellation or - filling. Failed cancellations are retried by the parent timer. + Await each request's response, but do not poll or wait for every child + to reach a final status. This is not a background task launcher. + A venue can accept the cancellation request while its response still + reports the order open. That is not a confirmed cancellation: record + the returned status and leave the child live. The caller must keep + processing updates or retry cancellation; this helper does not wait + for confirmation and does not itself schedule retries. """ for child in self.live_children: try: result = await ctx.cancel_child(child.order_id, child.venue) if result is not None: - self.note_order(result, child) + # Copy what the venue actually reported. A fill may have won + # the race, or the cancellation may still be pending. + self.update_child_from_venue_order(result, child) else: + # The existing cancel-child contract treats no returned + # order as canceled; this is distinct from an open response. child.status = "canceled" except Exception as exc: + # Failure to obtain a result is not proof of cancellation. + # Preserve the last child status for subsequent processing. log.debug("synpath.engine.orders: cancelling %s failed: %s", child.order_id, exc) async def finish(self, ctx: Context, state: State, detail: str = "") -> None: @@ -377,14 +391,21 @@ async def finish(self, ctx: Context, state: State, detail: str = "") -> None: await ctx.save() await ctx.publish(f"managed.{state}", {"kind": self.kind, "filled": str(self.filled), "detail": self.detail}) - def note_order(self, order: Order, child: Child) -> Fill | None: - """Take the venue's latest record of a child. Returns the progress - since the last record as a `Fill`-shaped step for the `on_fill` - hook, or `None` if nothing more filled. Built from the order record - alone: the venue's fill events go to the ledger, never here.""" + def update_child_from_venue_order(self, order: Order, child: Child) -> Fill | None: + """Update the tracked child from an already-obtained venue order result. + + This method makes no venue request. Its caller supplies an order from + a submission/cancellation response, stream update or explicit status read. + Return newly filled quantity as a `Fill`-shaped step for `on_fill`, or + None if no additional quantity filled. Separate venue fill events go + to the ledger, not directly to this parent's progress. + """ + # Ignore a result that would move known fills backwards. if order.filled < child.filled: return None before, fee_before = child.filled, child.fee + # Replace cumulative totals and status; do not add the same fills again + # when a stream repeats a result already seen in a placement or cancel response. child.filled = order.filled or ZERO if order.average_price is not None or child.filled > before: child.average_price = order.average_price @@ -392,6 +413,8 @@ def note_order(self, order: Order, child: Child) -> Fill | None: child.status = order.status.value self.updated_ms = now_ms() delta = child.filled - before + # No new fill does not mean no change: the child status above may + # have changed from open to canceled. Only the fill-progress return is None. if delta <= 0: return None return Fill( diff --git a/synpath/engine/orders/iceberg.py b/synpath/engine/orders/iceberg.py index 5e0cfc0..0fd55f6 100644 --- a/synpath/engine/orders/iceberg.py +++ b/synpath/engine/orders/iceberg.py @@ -89,7 +89,7 @@ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: self.schedule_reload(ctx) await ctx.save() - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if order.is_terminal and self.state == "working" and self.remaining > 0: self.schedule_reload(ctx) await self.complete_if_done(ctx) diff --git a/synpath/engine/orders/lifecycle/README.md b/synpath/engine/orders/lifecycle/README.md index c56283f..10d161a 100644 --- a/synpath/engine/orders/lifecycle/README.md +++ b/synpath/engine/orders/lifecycle/README.md @@ -1,12 +1,14 @@ # Order lifecycles -The four instruction types implemented on SQLite and PostgreSQL. These documents define -behavior and identify what the current checkpoint implements. +These documents explain order behavior and distinguish current implementation from draft proposals. The four instruction types below have the SQLite and PostgreSQL lifecycle work; routed bucket orders still use the existing managed-order path. - [Market](market.md) - [Limit](limit.md) - [Stop market](stop-market.md) - [Stop limit](stop-limit.md) +- [Shared managed states](managed-states.md): current internal states, public statuses and progress +- [Routed bucket order](routed.md): current transitions and lifecycle gaps +- [Routed lifecycle improvement plan](routed-lifecycle-plan.md): draft decisions and offline acceptance cases A stop's time in force is one for the whole order (armed, and after trigger its child), as in IBKR and FIX: GTC, GTD or DAY; IOC and FOK are refused. diff --git a/synpath/engine/orders/lifecycle/managed-states.md b/synpath/engine/orders/lifecycle/managed-states.md new file mode 100644 index 0000000..1e4d7c6 --- /dev/null +++ b/synpath/engine/orders/lifecycle/managed-states.md @@ -0,0 +1,57 @@ +# Managed order states in the current code + +Status: current implementation walkthrough, read on 22 October 2026 at SDK commit `5ae2269` with local readability refactors. Proposed changes belong in [the routed lifecycle plan](routed-lifecycle-plan.md). + +A managed order is an instruction the Engine continues running, such as a stop, iceberg or routed bucket order. Its state describes its execution phase. Its filled quantity is a separate measurement. This distinction lets an order be both canceled and partially filled. + +## Internal state and public status + +[`base.py`](../base.py) defines `State`, `LIVE_STATES` and `STATUS_OF`. `ManagedOrder.as_order()` translates internal state into the public `Order.status` using that shared mapping. + +| Internal state | Public status today | Meaning | +|---|---|---| +| `waiting` | `waiting` | Initial state; for a stop, its condition has not fired. For a bucket, this is only the initial state before `start()`. | +| `working` | `triggered` | The implementation has started its active behavior. For a routed order, read `triggered` as active managed execution, not a price trigger; the existing mapping is being retained. | +| `cancelling` | `pending_cancel` | The parent is trying to stop its children; it is not yet finished. | +| `done` | `closed` | The implementation considers its instruction complete. Routed orders use this for full fills. Other order types need their own completion rules. | +| `canceled` | `canceled` | The parent has ended without completing its requested quantity; fills may already exist. | +| `rejected` | `rejected` | An error path ended the managed order. This is distinct from rejecting a request before a parent is created. | +| `expired` | `expired` | The parent ended through the shared explicit-expiry path. Type-specific timers do not necessarily use this state. | + +Only `waiting`, `working` and `cancelling` are considered live. Final states are excluded from the controller's periodic processing. Therefore choosing a final state too early can prevent subsequent cleanup. + +The seven names form a shared vocabulary, not a complete transition specification for every type. The implementation classes decide which transitions occur. + +## Quantity is independent of state + +`ManagedOrder.filled` sums the latest recorded filled quantity of each child. `remaining` is `max(0, requested - filled)`. The controller's `on_order()` records a child's latest venue result, derives any new fill progress, and calls the parent's `on_fill()` and `on_child_order_updated()` methods. Separate venue fill notifications go to the ledger, not directly to parent progress. + +For an instruction to buy 100: + +| State | Filled | Remaining | Interpretation | +|---|---:|---:|---| +| `working` | 30 | 70 | Still trying to fill the remaining 70. | +| `cancelling` | 30 | 70 | Trying to stop; some of the remaining 70 may still fill. | +| `canceled` | 30 | 70 | Stopped with 30 filled and 70 unfilled. | +| `done` | 100 | 0 | Fully filled, for this routed order. | + +`remaining` is unfilled requested quantity, not necessarily quantity still resting at venues. A final canceled order can retain a positive `remaining`. + +## Who changes the state + +1. `ManagedOrderController.create()` constructs the implementation, saves and registers it, then calls its `start()` through `run()`. +2. The controller forwards book updates, child order updates and timer ticks to that implementation. +3. Type-specific methods decide whether to continue, stop or finish. `finish()` sets state and detail, saves, then publishes an event. +4. The shared `cancel()` sets `cancelling` before pulling children. It stays pending while children remain live; otherwise it ends `done` if filled or uses the requested final state. +5. Shared `expire()` calls `cancel(..., end="expired")`. The controller checks explicit `expires_at` on timer ticks. +6. `_run_hook()` handles errors and saves the resulting parent. Network errors and timeouts retain the current state with a detail message. Other errors can end `rejected`, subject to the implementation's recorded uncertain-child information. + +A call to `finish()` does not itself verify that every child is terminal. Each caller is responsible for satisfying that condition. Read type-specific paths before assuming the shared helpers enforce it. + +## Code reading order + +- [`base.py`](../base.py): `State`, `STATUS_OF`, `filled`, `cancel()`, `expire()`, `finish()`, `complete_if_done()`, `as_order()`. +- [`manager.py`](../manager.py): `create()`, `_run_hook()`, `on_order()`, `on_timer()`. +- [Routed order lifecycle](routed.md): the bucket-specific path and its current gaps. + +Storage transactions, restart guarantees and uncertain-send recovery are the next review phase. This walkthrough does not establish those guarantees; see the [engine README](../../README.md) for the existing shared design. diff --git a/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md b/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md new file mode 100644 index 0000000..67de743 --- /dev/null +++ b/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md @@ -0,0 +1,102 @@ +# Routed order lifecycle improvement plan + +Status: documentation and planning only. Keep the existing statuses. No runtime behavior, schema or API changes are authorized by this plan. Read [shared managed states](managed-states.md) and [current routed behavior](routed.md) first. + +## Agreed direction + +- Keep the current internal states and public status mapping, including `working` to `triggered`. This is a naming tradeoff, not an execution defect. Document `triggered` as active managed execution for routed orders; a routed parent has no stop-price trigger. +- Keep phase, reason and filled quantity separate. `canceled` with `filled=30`, `remaining=70` and `stop_reason=liquidity` is a useful and coherent outcome for 100 requested. +- Add consistent stop reasons to the to-do list. Record gaps now; do not implement them yet. +- Examine lifecycle transitions next, especially pending cancellation. Saving and recovery follow after the intended transitions are understood. + +## Stop reason to do list + +- [ ] List the current endings: user cancel, halt, worst price, liquidity, minimum quantity, round limit, maximum age, explicit expiry and execution errors. +- [ ] Specify a structured reason for each applicable ending so clients do not parse `detail` text. Existing routing reasons remain useful. +- [ ] Define which reason wins if events overlap, such as user cancellation followed by expiry or a full fill during cancellation. +- [ ] Decide what to retain when cancellation is still pending: the original stopping cause, intended final state and latest child outcomes. +- [ ] Specify where those fields appear in a normal order response versus a bucket report before changing contracts. +- [ ] Add offline tests after the transition rules are agreed. + +The issue is consistent population across paths, not a need to combine reason and fill progress into one status. + +## Current lifecycle in plain language + +| Step | Internal state | Public status | Relevant code | +|---|---|---|---| +| Parent is constructed | `waiting` | `waiting` | `ManagedOrder.__init__()` | +| Routing starts | `working` | `triggered` | `RoutedLimit.start()` | +| Book changes or part of the quantity fills | Usually still `working` | `triggered` | `on_book()`, `on_fill()`, `on_child_order_updated()`, `_replan()` | +| User cancellation begins | `cancelling` | `pending_cancel` | Inherited `ManagedOrder.cancel()` | +| Every child is resolved, requested quantity filled | `done` | `closed` | Completion paths | +| Every child is resolved, some requested quantity remains | `canceled`, or `expired` for explicit expiry | Same as internal state | Cancellation or expiry path | + +Routing-boundary `stop()` is a separate path: it sets `stop_reason`, pulls children, and only sets `cancelling` if children are still live afterwards. Thus it can call venues while the parent is still `working`. `max_age` currently ends `canceled` when incomplete, while explicit expiry requests `expired`. Whether to align these endings remains open; neither is changed by this plan. + +## Walkthrough of delayed cancellation + +Example: buy 100; 30 filled and 70 remain resting at one venue. + +1. User cancels. Inherited `cancel()` sets `cancelling` and saves before requesting child cancellation. +2. The venue acknowledges the request but still reports the child open. `cancel_live_children_without_waiting()` keeps that child live, so the parent remains `cancelling` with 30 filled. +3. Later, the venue reports the child canceled. The controller updates the child's record through `update_child_from_venue_order()` before invoking parent callbacks. +4. Routed `on_child_order_updated()` calls `_ready()`, which returns false unless the parent is `working`. It returns without finishing the `cancelling` parent. +5. An ordinary routed timer tick has the same gate and also returns. Unlike repeated explicit expiry, it does not retry the inherited cancellation path. + +This explains a potential stuck transition: child records can be correct while the parent remains pending. The code path is observed; a dedicated delayed-acknowledgment test should reproduce it before a fix is designed. + +## Transition rules to review + +These rules are proposed behavior, not a claim that every current path enforces them. + +| Transition or observation | Intended rule | +|---|---| +| `waiting` to `working` | Start this accepted routed instruction. | +| `working` after a partial fill | Update quantities; continue only for the remainder. | +| `working` to `cancelling` | Stop allocating before child cancellation calls, whether the cause is user action or a routing boundary. | +| Child update while `cancelling` | Keep recording progress, clean up child state and check whether the pending ending can complete. Never resume routing. | +| Timer while `cancelling` | Retry necessary cleanup or check progress; do not place replacement orders. | +| `cancelling` to `done` | Full quantity filled and no live or unresolved child can add exposure. | +| `cancelling` to `canceled` or `expired` | Incomplete, but children are resolved; use the intended ending policy. | +| Any proposed final state | No outstanding child can still change exposure without being represented. Failure is not proof of rejection. | + +If there is nothing left to cancel, stopping can finish within the same call. No extra state is needed just to show a partial fill. Whether a failure after partial execution should end `canceled` or `rejected` remains an explicit decision, not an accepted change. + +```mermaid +stateDiagram-v2 + [*] --> waiting + waiting --> working: start + working --> working: partial fill or revised allocation + working --> done: fully filled and children resolved + working --> cancelling: request stopping + cancelling --> cancelling: children still open or unresolved + cancelling --> done: final fills complete the instruction + cancelling --> canceled: incomplete and cancellation confirmed + cancelling --> expired: incomplete and expiry cleanup complete +``` + +This focuses on normal execution and stopping. Safe rejection and pre-parent validation failure need their own reviewed cases. It does not assert that `stop()` currently enters `cancelling` before venue calls. + +## Offline cases before implementation + +- [ ] Ordinary full fill and partial fill retain correct quantities and existing public statuses. +- [ ] Immediate cancellation completes with the expected quantity and reason. +- [ ] Cancellation acknowledgment still showing open stays pending. +- [ ] A later child cancellation completes that pending parent. +- [ ] A cancellation failure is retried without allocating new quantity. +- [ ] A final fill during cancellation yields full completion; a partial fill is retained while cancellation continues. +- [ ] Book updates and reentrant child callbacks during stopping cannot submit replacements. +- [ ] Routing-boundary stop and explicit expiry obey the same cleanup rules, with their chosen final-state policy. +- [ ] No-books and error paths do not imply confirmed cancellation or safe rejection. + +Use existing paper/scripted venues. Do not place credentialed or real-money orders for this work. + +## Bridge to saving and recovery + +Once the transition rules are agreed, identify the information a restart must preserve: execution phase, stopping cause, intended final outcome, child identities and each child's last known progress or uncertainty. Then examine how to store it and how to reconcile it before sending anything new. + +That later review covers pending child identity, response loss, unavailable venue reads, transaction boundaries and migration choices. This plan does not choose a new table, remove the shared order copy or implement recovery changes. Bucket synchronization, amendments, additional bucket order types and lifetime-setting support remain separate scope. + +## Next code reading session + +Read inherited `cancel()` in `base.py`, then `cancel_live_children_without_waiting()`. Follow the delayed venue result through `ManagedOrderController.on_order()` into `RoutedLimit.on_child_order_updated()` and `_ready()`. Compare that with routed `stop()` and `on_timer()`. These call sites show why entering a state is only half of a lifecycle: something must also advance it to its ending. diff --git a/synpath/engine/orders/lifecycle/routed.md b/synpath/engine/orders/lifecycle/routed.md new file mode 100644 index 0000000..4038f02 --- /dev/null +++ b/synpath/engine/orders/lifecycle/routed.md @@ -0,0 +1,52 @@ +# Routed bucket order lifecycle + +Status: current behavior, not the proposed contract. Read alongside [shared managed states](managed-states.md) and [the draft improvement plan](routed-lifecycle-plan.md). + +## Accepted instruction + +[`Engine._submit_bucket()`](../../engine.py) accepts only `type="market"` with a required `price`: the worst accepted price in bucket terms. The bucket must be saved and active, have an enabled member, and have adapters for its enabled venues. The parent captures the enabled-member definition; later bucket edits do not change that order. + +[`RoutedLimit`](../routed.py) manages this instruction despite its historical class name. Its optional settings include `min_stay_s`, `max_rounds`, `max_age_s` and precision overrides. Zero routing limits mean disabled bounds. Size and price validation also comes from `OrderRequest` and risk checks. These are parent instructions; children are priced GTC limit orders. + +## Current transitions + +| Observation or action | Current transition and behavior | +|---|---| +| Construct parent | Begins `waiting`; it has no price trigger to wait for. | +| `start()` | Sets `working`, records start time, and plans immediately. Public status is currently `triggered`. | +| Books change or child progress arrives | While ready and `working`, replan remaining quantity and keep, cancel or add children. A partial fill normally leaves the parent `working`. | +| No member books available | Remain `working`, publish `reason=no_books`, save and wait. | +| Full fill | Completion paths use `done` / public `closed`. `complete_if_done()` also requires no live children. Some other routed paths pull children then call `finish()` directly. | +| Plan has an unfilled remainder and a reason, with no live children | `stop(reason)` ends `canceled` if incomplete. Reasons include `worst_price`, `liquidity`, `min_quantity`. | +| `max_rounds` reached | `stop("max_rounds")` pulls children, then ends `canceled` if incomplete or waits in `cancelling` if children remain live. | +| `max_age_s` reached on timer | Same stop path with `max_age`; currently an incomplete order ends `canceled`, not `expired`. | +| User cancel or halt | Inherited `cancel()` enters `cancelling`, pulls children, then ends `done` if filled or `canceled` if incomplete and no live children remain. | +| Explicit `expires_at` reached | Shared timer calls inherited `expire()`, requesting final state `expired` after children are stopped. | +| Error during a controller callback | Shared `_run_hook()` handles it. A parent can become `rejected`; uncertainty handling is not equivalent for all order types. | + +`max_rounds` counts later planning rounds that actually placed or pulled an order. The first placement does not increment it. `max_age_s` is elapsed time from `start()`, checked by the timer; it is not a venue-native order expiry. + +Explicit expiry plumbing exists, so “bucket orders can never expire” is inaccurate. Bucket submission bypasses the ordinary DAY-to-absolute-expiry conversion, however, and bucket-specific time-in-force semantics are not fully specified. GTD, DAY, IOC and FOK acceptance and behavior need dedicated decisions and tests. + +## Current outcome fields + +`as_order()` exposes public status, filled and remaining quantity. `report()` additionally exposes internal `state`, `stop_reason`, `filled`, `unfilled`, rounds and per-venue results. A stopped partial fill is structured data, not only prose. + +For 100 requested, 30 filled and no liquidity remaining, the routed report can say `state=canceled`, `filled=30`, `unfilled=70`, `stop_reason=liquidity`. A client can display “30/100 filled · stopped: liquidity”. This does not require a separate partial-fill state. + +The inherited cancel/expiry path does not populate routed `stop_reason` in the same way as `stop()`. General error paths also use detail text. The reason is therefore not consistent across every ending. + +## Lifecycle behavior to review + +- The shared mapping names active routing `triggered`. We are keeping that mapping; document it as active managed execution rather than treating it as a defect. +- `max_age` and explicit expiry use different final states for time-based endings. +- `stop()` attempts child cancellation before setting `cancelling`, unlike the shared cancel path. Child updates can call back into the parent while it is still working. +- After `cancelling`, routed `_ready()` returns false. Routed `on_timer()` and `on_child_order_updated()` then skip their ordinary work. General pending-cancellation retry and completion are not clearly covered by that path; repeated explicit expiry has separate controller handling. +- Some full-fill/error paths can call `finish()` without a shared guarantee that all children are resolved. Tests must establish when a final state is safe. +- End reasons differ between routing stop, user cancellation, expiry and errors. + +These are code-path observations, not a claim that every scenario has been reproduced. The draft plan identifies the offline cases needed before changing behavior. + +## Next phase boundary + +The Engine owns the instruction; venues own child acceptance and fills. Current routing progress is saved through the managed snapshot path and child records. This phase changes neither storage nor restart behavior. The following phase must assess saved child identities, failed venue reads, uncertain sends and cancellation races before live guarantees can be claimed. diff --git a/synpath/engine/orders/manager.py b/synpath/engine/orders/manager.py index ae929c4..9df1a09 100644 --- a/synpath/engine/orders/manager.py +++ b/synpath/engine/orders/manager.py @@ -1,6 +1,6 @@ """The supervisor: every engine-held order, and what reaches it. -One `ManagedOrders` per engine. It creates parents, hands them the events +One `ManagedOrderController` per engine. It creates parents, hands them the events they asked for, writes their state down whenever it changes, and brings them back after a restart. Three details are worth stating, because they are what makes an engine-held order safe rather than a background task: @@ -40,7 +40,7 @@ def register(cls: Type[ManagedOrder]) -> Type[ManagedOrder]: return cls -class ManagedOrders: +class ManagedOrderController: """Every engine-held order this engine is running.""" def __init__(self, engine: Any): @@ -78,6 +78,7 @@ def watching(self, market_id: str) -> list[ManagedOrder]: # -- creating ------------------------------------------------------------- def build(self, request: OrderRequest, *, venue: str, account: Any) -> ManagedOrder: + """Construct the requested order implementation without saving or starting it.""" # `managed_kind` lets one venue type map to an engine-held variant: # a market order the engine walks the book with, rather than the # adapter's single immediate limit. @@ -91,20 +92,38 @@ def build(self, request: OrderRequest, *, venue: str, account: Any) -> ManagedOr return cls(request, venue=venue, account=account) async def create(self, request: OrderRequest, *, venue: str, account: Any, owner: ManagedOrder | None = None) -> ManagedOrder: + """Save and register an engine-held order, announce it, then start its behavior. + + `owner` is another managed order, not a user: for example, a bracket + can own a stop order. The returned order may already have placed or + filled venue children, or encountered a failure while starting. + """ + # Choose the implementation (for example, RoutedLimit for a bucket). + # A managed order can itself be a child of another managed order. parent = self.build(request, venue=venue, account=account) parent.owner_id = owner.id if owner else None + + # Persist the initial instruction before start() can send venue orders. await self.save(parent) + + # Register in-memory lookups so market and child updates reach this order. + # The owner link also lets a nested managed order report back to its owner. self.remember(parent) if owner is not None: self.by_child[parent.id] = owner.id + + # Creation means the order exists, not that a venue has accepted a child. await self.engine.bus.publish("managed.created", { "parent_id": parent.id, "kind": parent.kind, "market_id": parent.market_id, "side": parent.side.value, "quantity": str(parent.quantity), "book": request.book, }, key=parent.id) + # Await the type-specific start behavior through the controller's locking, + # error handling and final save. A routed order plans and places children here. await self.run(parent, lambda ctx: parent.start(ctx)) return parent def remember(self, parent: ManagedOrder) -> None: + """Index an order by ID, watched markets and known children, in memory only.""" self.parents[parent.id] = parent for instrument in parent.markets(): self.by_market.setdefault(instrument, set()).add(parent.id) @@ -118,7 +137,11 @@ def forget(self, parent: ManagedOrder) -> None: # -- routing -------------------------------------------------------------- async def run(self, parent: ManagedOrder, call: Any) -> None: - """Serialize each parent; child notifications may reenter on this task.""" + """Run one order's behavior without overlapping another task for that order. + + The callback receives a Context for books, child submission and saving. + Child notifications on the current task may call back into the same order. + """ task = asyncio.current_task() if self._running_tasks.get(parent.id) is task: await self._run_hook(parent, call) @@ -172,7 +195,7 @@ async def tell_owner(self, leg: ManagedOrder) -> None: return child.status = leg.state child.filled = leg.filled - await self.run(owner, lambda ctx: owner.on_child(ctx, leg.as_order(), child)) + await self.run(owner, lambda ctx: owner.on_child_order_updated(ctx, leg.as_order(), child)) async def on_book(self, market_id: str) -> None: watching = self.watching(market_id) @@ -209,10 +232,13 @@ async def apply(ctx): child = parent.child_of(order.id, order.venue) if child is None: return - progress = parent.note_order(order, child) + # First update the tracked child, even when no new quantity filled. + progress = parent.update_child_from_venue_order(order, child) if progress is not None: await parent.on_fill(ctx, progress, child) - await parent.on_child(ctx, order, child) + # Then let the parent react to the updated child. A correct child + # record alone does not advance the parent's lifecycle state. + await parent.on_child_order_updated(ctx, order, child) await self.run(parent, apply) async def on_timer(self) -> None: @@ -247,6 +273,7 @@ async def cancel(self, parent_id: str, *, reason: str = "cancelled by the caller # -- persistence ---------------------------------------------------------- async def save(self, parent: ManagedOrder) -> None: + """Save the restart snapshot and readable order record, and update local lookups.""" for child in parent.children: self.by_child.setdefault(child.order_id, parent.id) self.by_child.setdefault(f"{child.venue}:{child.order_id}", parent.id) diff --git a/synpath/engine/orders/oco.py b/synpath/engine/orders/oco.py index cc8dbf4..9d4bd8c 100644 --- a/synpath/engine/orders/oco.py +++ b/synpath/engine/orders/oco.py @@ -135,7 +135,7 @@ async def cancel_other(self, ctx: Context, tag: str, reason: str) -> None: child.status = "canceled" await ctx.publish("managed.leg_canceled", {"leg": tag, "reason": reason}) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if not order.is_terminal or self.state != "working": return if self.remaining <= 0: @@ -230,7 +230,7 @@ def entry_done(self) -> bool: entry_children = self.leg_children("entry") return bool(entry_children) and not any(c.live for c in entry_children) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: tag = self.leg_of.get(child.order_id, "") if self.state != "working" or not order.is_terminal: return diff --git a/synpath/engine/orders/peg.py b/synpath/engine/orders/peg.py index 18f47e9..bf773cf 100644 --- a/synpath/engine/orders/peg.py +++ b/synpath/engine/orders/peg.py @@ -141,7 +141,7 @@ async def reprice(self, ctx: Context, *, first: bool = False) -> None: async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: await self.complete_if_done(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state != "working" or not order.is_terminal: return if self.remaining <= 0: diff --git a/synpath/engine/orders/routed.py b/synpath/engine/orders/routed.py index bef5b05..fae40be 100644 --- a/synpath/engine/orders/routed.py +++ b/synpath/engine/orders/routed.py @@ -77,8 +77,10 @@ def of(cls, row: dict[str, Any]) -> "LegState": @register class RoutedLimit(ManagedOrder): - """`params`: `bucket` (its definition; the engine fills this in from the - journal), `min_stay_s`, `max_rounds`, `max_age_s`, `precision`.""" + """`params`: `bucket` (the enabled-member configuration captured at + submission and kept for this order, including after restart), `min_stay_s`, + `max_rounds`, `max_age_s`, `precision`. Later edits to the saved bucket + do not change this order's member configuration.""" kind = "routed_limit" order_type = OrderType.MARKET @@ -177,7 +179,7 @@ async def resync(self, ctx: Context) -> None: await ctx.publish("managed.resync_failed", {"order_id": child.order_id, "venue": child.venue, "error": f"{type(exc).__name__}: {exc}"}) continue - progress = self.note_order(order, child) # the record read back overwrites the snapshot's + progress = self.update_child_from_venue_order(order, child) # the record read back overwrites the snapshot's if progress is not None: self.fees_paid += progress.fee or ZERO known = {self.key(c) for c in self.children} @@ -188,7 +190,7 @@ async def resync(self, ctx: Context) -> None: _, bucket_price = member.to_bucket(order.side, order.price or ZERO) child = self.track(order, order.quantity, bucket_price) child.fee = order.fee or ZERO - ctx.engine.orders.adopt_child(self.id, order.id, order.venue) + ctx.engine.managed_order_controller.adopt_child(self.id, order.id, order.venue) if child.live and order.market_id not in self.legs: self.legs[order.market_id] = LegState(order_id=order.id, venue=order.venue, side=order.side.value, price=order.price or ZERO, bucket_price=bucket_price, @@ -197,12 +199,13 @@ async def resync(self, ctx: Context) -> None: await ctx.publish("managed.resynced", {"children": len(self.children), "filled": str(self.known_filled)}) if self.state == "working": if self.remaining <= 0: - await self.pull_children(ctx) + await self.cancel_live_children_without_waiting(ctx) await self.finish(ctx, "done", "filled") else: await self.replan(ctx) async def _ready(self, ctx: Context) -> bool: + """Refresh restored children if needed, then allow only active routing.""" if not self._resynced: await self.resync(ctx) return self.state == "working" @@ -216,12 +219,17 @@ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: if not await self._ready(ctx): return if self.remaining <= 0: - await self.pull_children(ctx) + await self.cancel_live_children_without_waiting(ctx) await self.finish(ctx, "done", "filled") return await self.replan(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: + """React to an ended child: remove its leg and consider the remaining quantity.""" + # The controller has already updated the child's status and fill totals. + # Current limitation: _ready() rejects a cancelling parent, so even a + # confirmed child cancellation returns here without finishing that parent. + # Pending-cancellation completion is tracked in the lifecycle plan. if not await self._ready(ctx) or not order.is_terminal: return for market_id, leg in list(self.legs.items()): @@ -233,6 +241,8 @@ async def on_child(self, ctx: Context, order: Order, child: Child) -> None: await self.replan(ctx) async def on_timer(self, ctx: Context) -> None: + # This has the same working-only gate: ordinary timer ticks currently + # do not retry cancellation or finish a parent already cancelling. if not await self._ready(ctx): return if self.max_age_s and ctx.now - self.started_at >= self.max_age_s: @@ -244,7 +254,7 @@ async def on_timer(self, ctx: Context) -> None: async def stop(self, ctx: Context, reason: str) -> None: """Stand down with what filled. `done` only when it all did.""" self.stop_reason = reason - await self.pull_children(ctx) + await self.cancel_live_children_without_waiting(ctx) if self.remaining <= 0: await self.finish(ctx, "done", "filled") elif self.live_children: @@ -260,7 +270,7 @@ def may_move(self, ctx: Context, leg: LegState) -> bool: async def replan(self, ctx: Context, *, first: bool = False) -> None: """Re-entrant safe: a cancel inside a plan can hand the engine a - terminal child, whose `on_child` asks for a plan of its own. That + terminal child, whose `on_child_order_updated` asks for a plan of its own. That request is noted and honoured once this plan is through.""" if self._replanning: self._replan_again = True @@ -276,92 +286,154 @@ async def replan(self, ctx: Context, *, first: bool = False) -> None: self._replan_again = False async def _replan(self, ctx: Context, *, first: bool = False) -> None: + """Reconcile the new allocation with existing children before adding orders.""" if self.known_remaining <= 0: - # Everything is filled by the venue's word; the fills the stream - # has not delivered yet finish it through `on_fill`. await self.complete_if_done(ctx) return + + canceled_count = await self._clean_up_existing_legs(ctx) + member_books = self._read_member_books(ctx) + if not member_books: + await self._save_while_waiting_for_books(ctx, canceled_count=canceled_count, first=first) + return + + plan = await self._calculate_routing_plan(ctx, member_books) + proposed_legs = {leg.market_id: leg for leg in plan.legs} + canceled_count += await self._keep_or_cancel_existing_legs(ctx, member_books, proposed_legs) + placed_count = await self._place_new_legs(ctx, proposed_legs) + await self._record_routing_result( + ctx, plan, placed_count=placed_count, canceled_count=canceled_count, first=first, + ) + + async def _clean_up_existing_legs(self, ctx: Context) -> int: + """Remove inactive legs and cancel excess resting quantity, newest first.""" self._forget_dead_legs() - pulled = await self._trim(ctx) - books = {} + return await self._trim(ctx) + + def _read_member_books(self, ctx: Context) -> dict[str, router.BookView]: + """Read available member books, with flipped members expressed in bucket terms.""" + member_books = {} for member in self.bucket.members: book = ctx.book(member.market_id) if book is not None: - books[member.market_id] = router.view(book, member) - if not books: - # No book for any member yet (a restart before the streams are - # back): nothing to plan against, and no reason to pull what - # rests beyond what the trim took. Wait for a book. - if not first and pulled: - self.rounds += 1 - await ctx.publish("managed.routed", {"round": self.rounds, "placed": 0, "pulled": pulled, - "resting": len(self.legs), "remaining": str(self.known_remaining), - "reason": "no_books"}) - await ctx.save() - return - await self._price_fees(ctx, books) - plan = router.plan(self.bucket, books, self.precision, self._fee, - side=self.side, quantity=self.known_remaining, limit=self.limit, floors=self.fee_floors) - wanted = {leg.market_id: leg for leg in plan.legs} - - # Keep a leg that is still at a price the plan wants, as long as what - # is kept does not add up to more than is left; pull one whose price - # is wrong, unless it has not sat for min_stay_s yet. Shrinking a leg - # is a cancel and a re-place at the back of the queue, so a leg is - # not pulled merely because the plan would size it smaller today. A - # kept leg is not topped up in the same round. - kept_resting = ZERO + member_books[member.market_id] = router.view(book, member) + return member_books + + async def _save_while_waiting_for_books( + self, ctx: Context, *, canceled_count: int, first: bool, + ) -> None: + """Keep resting orders when no books are available; save cleanup progress and wait.""" + # A restart may restore children before their market streams return. + # Missing books alone do not justify cancelling those resting orders. + if not first and canceled_count: + self.rounds += 1 + await ctx.publish("managed.routed", { + "round": self.rounds, "placed": 0, "pulled": canceled_count, + "resting": len(self.legs), "remaining": str(self.known_remaining), "reason": "no_books", + }) + await ctx.save() + + async def _calculate_routing_plan( + self, ctx: Context, member_books: dict[str, router.BookView], + ) -> router.Plan: + """Price the available liquidity and propose legs for all remaining contracts.""" + await self._price_fees(ctx, member_books) + return router.plan( + self.bucket, member_books, self.precision, self._fee, + side=self.side, quantity=self.known_remaining, limit=self.limit, floors=self.fee_floors, + ) + + async def _keep_or_cancel_existing_legs( + self, ctx: Context, member_books: dict[str, router.BookView], + proposed_legs: dict[str, router.Leg], + ) -> int: + """Keep suitable children and cancel movable ones that no longer fit. + + Remove each kept market from `proposed_legs` in place, so the placement + stage cannot submit another child there during this round. + """ + canceled_count = 0 + kept_resting_quantity = ZERO for market_id, leg in list(self.legs.items()): child = self.child_of(leg.order_id, leg.venue) if child is None or not child.live: self.legs.pop(market_id, None) continue - want = wanted.get(market_id) - resting = child.quantity - self.known(child) - if market_id not in books: - # No view of this member's book: nothing says the leg is wrong. - kept_resting += resting - wanted.pop(market_id, None) + proposed_leg = proposed_legs.get(market_id) + resting_quantity = child.quantity - self.known(child) + + # No current book for this market: keep its child, because missing + # data alone is not evidence that the resting order should move. + if market_id not in member_books: + kept_resting_quantity += resting_quantity + proposed_legs.pop(market_id, None) continue - if (want is not None and want.price == leg.price and want.side == leg.side - and kept_resting + resting <= self.known_remaining): - kept_resting += resting - wanted.pop(market_id) + + # Same side and price, with room in the remaining quantity: keep + # the queue position. Do not shrink to the proposal's size, since + # cancel/replacement would lose priority, or top it up this round. + if (proposed_leg is not None and proposed_leg.price == leg.price and proposed_leg.side == leg.side + and kept_resting_quantity + resting_quantity <= self.known_remaining): + kept_resting_quantity += resting_quantity + proposed_legs.pop(market_id) continue + + # The proposal no longer fits, but the minimum stay has not passed. + # Keep the child for now; budget() still reserves its outstanding size. if not self.may_move(ctx, leg): - wanted.pop(market_id, None) + proposed_legs.pop(market_id, None) continue + + # The child no longer fits and is old enough to move. Cancel it; + # any replacement will come from the remaining proposed legs. self.legs.pop(market_id, None) await ctx.cancel_child(child.order_id, child.venue) child.status = "canceled" - pulled += 1 - - # Never put out more than is left once every live leg is reserved. - budget = self.budget() - placed = 0 - for market_id, want in wanted.items(): - size = min(want.quantity, budget) - spec = self.precision[market_id] - if size <= 0 or size < spec.min_quantity: + canceled_count += 1 + return canceled_count + + async def _place_new_legs(self, ctx: Context, proposed_legs: dict[str, router.Leg]) -> int: + """Submit remaining proposals within the unreserved quantity and venue minimums.""" + # Reserve every live child's size before adding orders, including those + # kept despite missing books or an unelapsed minimum stay. + available_quantity = self.budget() + placed_count = 0 + for market_id, proposed_leg in proposed_legs.items(): + quantity = min(proposed_leg.quantity, available_quantity) + precision = self.precision[market_id] + if quantity <= 0 or quantity < precision.min_quantity: continue - if spec.min_notional is not None and want.notional() * size / want.quantity < spec.min_notional: + if (precision.min_notional is not None + and proposed_leg.notional() * quantity / proposed_leg.quantity < precision.min_notional): continue - request = self.child_request(market_id=market_id, side=want.side, price=want.price, quantity=size, - type=OrderType.LIMIT, time_in_force=TimeInForce.GTC) + request = self.child_request( + market_id=market_id, side=proposed_leg.side, price=proposed_leg.price, quantity=quantity, + type=OrderType.LIMIT, time_in_force=TimeInForce.GTC, + ) order = await ctx.submit_child(request) - child = self.track(order, size, want.bucket_price) # the child's price in bucket terms + # Record both the venue's response and the intended allocation. + # Child progress uses bucket prices; LegState retains the native price. + child = self.track(order, quantity, proposed_leg.bucket_price) child.fee = order.fee or ZERO - self.legs[market_id] = LegState(order_id=order.id, venue=order.venue, side=want.side.value, price=want.price, - bucket_price=want.bucket_price, quantity=size, placed_at=ctx.now, - round=self.rounds) - budget -= size - placed += 1 - if not first and (placed or pulled): - # A round is a re-allocation that changed something. The re-plans - # a cancel's own echo triggers change nothing and do not count. + self.legs[market_id] = LegState( + order_id=order.id, venue=order.venue, side=proposed_leg.side.value, price=proposed_leg.price, + bucket_price=proposed_leg.bucket_price, quantity=quantity, placed_at=ctx.now, + round=self.rounds, + ) + available_quantity -= quantity + placed_count += 1 + return placed_count + + async def _record_routing_result( + self, ctx: Context, plan: router.Plan, *, placed_count: int, canceled_count: int, first: bool, + ) -> None: + """Report the round, stop at the applicable bounds, otherwise save working state.""" + # Count only later rounds that changed an order. Replans caused by a + # cancellation's own notification do not count unless they change something. + if not first and (placed_count or canceled_count): self.rounds += 1 await ctx.publish("managed.routed", { - "round": self.rounds, "placed": placed, "pulled": pulled, "resting": len(self.legs), + "round": self.rounds, "placed": placed_count, "pulled": canceled_count, "resting": len(self.legs), "remaining": str(self.known_remaining), "unfilled": str(plan.unfilled), "reason": plan.reason, "expected_net_price": str(plan.expected_net_price) if plan.expected_net_price is not None else None, diff --git a/synpath/engine/orders/stop.py b/synpath/engine/orders/stop.py index 155f427..7b77f5b 100644 --- a/synpath/engine/orders/stop.py +++ b/synpath/engine/orders/stop.py @@ -202,7 +202,7 @@ async def resume_pending(self, ctx: Context, *, allow_send: bool = True) -> None if self.pending_end: await self.cancel(ctx, self.detail, end=self.pending_end) else: - await self.on_child(ctx, order, self.child_of(order.id, order.venue)) + await self.on_child_order_updated(ctx, order, self.child_of(order.id, order.venue)) return if intent is not None and intent.state in (IntentState.REJECTED, IntentState.FAILED, IntentState.SETTLED): self.pending_child = None @@ -243,7 +243,7 @@ async def cancel(self, ctx: Context, reason: str = "cancelled", *, end: str = "c async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: await self.complete_if_done(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if not order.is_terminal or self.state != "working": return if self.remaining <= 0: diff --git a/synpath/engine/orders/taker.py b/synpath/engine/orders/taker.py index 4c1f915..919bbee 100644 --- a/synpath/engine/orders/taker.py +++ b/synpath/engine/orders/taker.py @@ -120,7 +120,7 @@ async def start(self, ctx: Context) -> None: if not await self.take(ctx, self.remaining): await self.finish(ctx, "canceled", "nothing on the book inside the price bound") - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state != "working" or not order.is_terminal: return if self.remaining <= 0: @@ -169,7 +169,7 @@ async def on_timer(self, ctx: Context) -> None: return await self.clip_now(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state == "working" and self.remaining <= 0 and not self.live_children: await self.finish(ctx, "done", "filled") diff --git a/synpath/engine/orders/twap.py b/synpath/engine/orders/twap.py index 891c3ab..86af2a1 100644 --- a/synpath/engine/orders/twap.py +++ b/synpath/engine/orders/twap.py @@ -165,7 +165,7 @@ async def finish_window(self, ctx: Context) -> None: async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: await self.complete_if_done(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state != "working" or not order.is_terminal: return if self.remaining <= 0: diff --git a/synpath/engine/risk.py b/synpath/engine/risk.py index 5c5e38b..897dce0 100644 --- a/synpath/engine/risk.py +++ b/synpath/engine/risk.py @@ -212,24 +212,24 @@ def realized_today(self) -> Decimal: # -- the check ------------------------------------------------------------ - def check( + def evaluate_order_risk( self, request: OrderRequest, *, venue: str, account: Account, open_orders: Iterable[Order] = (), - mark: Decimal | None = None, + risk_reference_price: Decimal | None = None, market_close_ts: int | None = None, event_id: str | None = None, venue_notional: Decimal | None = None, - deliberate: bool = False, + caller_supplied_order_id: bool = False, ) -> Decision: """Everything that must be true before this order is signed. - `deliberate` is true when the caller named the order's - `client_order_id` itself: the duplicate window is for an accidental - second click, and a caller that names each order is not clicking.""" + `risk_reference_price` is the reference for the limit-price risk check. + `caller_supplied_order_id` skips the accidental-duplicate check when true; + retries with the same ID remain subject to the engine's submission checks.""" config = self.config if not config.enabled: return self._ok() @@ -249,14 +249,14 @@ def check( # The collar is for a limit a caller sends. A market order's price # is only its protection bound (Kalshi's 0.99 / 0.01), and an # engine-held order's child (a stop that fired) trades at the - # market on purpose: neither is a quote to check against the mark. + # market on purpose: neither is a quote to check against the risk reference price. collared = request.type == OrderType.LIMIT and not request.tags.get("parent") - if config.price_collar is not None and mark is not None and collared: - away = abs(price - mark) + if config.price_collar is not None and risk_reference_price is not None and collared: + away = abs(price - risk_reference_price) if away > config.price_collar: return self._no( - "price_collar", f"price {price} is {away} from the mark {mark}, over the collar {config.price_collar}", - detail={"mark": str(mark), "away": str(away)}, + "price_collar", f"price {price} is {away} from the mark {risk_reference_price}, over the collar {config.price_collar}", + detail={"mark": str(risk_reference_price), "away": str(away)}, ) if config.max_order_contracts is not None and request.quantity > config.max_order_contracts: return self._no("max_order_contracts", f"{request.quantity} contracts is over the {config.max_order_contracts} limit") @@ -273,7 +273,7 @@ def check( # An engine-held order's children are sliced on purpose and often look # alike (an iceberg's reloads, a TWAP's slices, two parents on one # market), so the window applies to orders sent from outside only. - if config.duplicate_window_ms and not deliberate and not request.tags.get("parent"): + if config.duplicate_window_ms and not caller_supplied_order_id and not request.tags.get("parent"): signature = (market_id, request.side.value, str(price), str(request.quantity)) cutoff = now - config.duplicate_window_ms / 1000 for stamp, seen in reversed(self._recent): diff --git a/synpath/server/trading.py b/synpath/server/trading.py index 2144b8d..8539a69 100644 --- a/synpath/server/trading.py +++ b/synpath/server/trading.py @@ -116,7 +116,7 @@ def orders_cursor_key(cursor: str | None) -> tuple[int, str, str] | None: def current_view(engine: Engine, order: Order) -> Order: """An engine-held parent this process runs is read from memory, which is what its journal row was last written from; anything else is the row.""" - parent = engine.orders.get(order.id) if order.held_by.value == "engine" else None + parent = engine.managed_order_controller.get(order.id) if order.held_by.value == "engine" else None return parent.as_order() if parent is not None else order @@ -519,10 +519,10 @@ async def accounts(principal: Caller, engine: EngineDep) -> list[AccountView]: async def status(principal: Caller, engine: EngineDep) -> EngineStatus: require(principal, "view") return EngineStatus( - owner=engine.journal.owner, running=engine.running, halted=engine.risk.kill.engaged, - halt_reason=engine.risk.kill.reason, open_orders=len(engine.open_orders()), - managed_orders=len(engine.orders.live()), positions=len(engine.ledger.open_positions()), - risk_version=engine.risk.config_version, journal_events=await engine.journal.last_seq(), + owner=engine.journal.owner, running=engine.running, halted=engine.order_risk_checks.kill.engaged, + halt_reason=engine.order_risk_checks.kill.reason, open_orders=len(engine.open_orders()), + managed_orders=len(engine.managed_order_controller.live()), positions=len(engine.ledger.open_positions()), + risk_version=engine.order_risk_checks.config_version, journal_events=await engine.journal.last_seq(), ) # -- orders --------------------------------------------------------------- @@ -705,7 +705,7 @@ async def list_orders( @router.get("/orders/{order_id}", tags=["orders"], summary="One order", responses=RESPONSES) async def get_order(principal: Caller, engine: EngineDep, order_id: Annotated[str, Path()]) -> Order: - parent = engine.orders.get(order_id) + parent = engine.managed_order_controller.get(order_id) order = parent.as_order() if parent is not None else None if order is None: for venue in engine.adapters: @@ -745,7 +745,7 @@ async def cancel_order(principal: Caller, engine: EngineDep, order_id: Annotated raise async def _known(engine: Engine, order_id: str) -> Order: - parent = engine.orders.get(order_id) + parent = engine.managed_order_controller.get(order_id) if parent is not None: return parent.as_order() for venue in engine.adapters: @@ -843,13 +843,13 @@ async def get_journal( @router.get("/risk", tags=["risk"], summary="The rules in force", responses=RESPONSES) async def get_risk(principal: Caller, engine: EngineDep) -> RiskConfig: require(principal, "view") - return engine.risk.config + return engine.order_risk_checks.config @router.put("/risk", tags=["risk"], summary="Replace the rules", responses=RESPONSES) async def put_risk(principal: Caller, engine: EngineDep, body: RiskConfig) -> RiskConfig: require(principal, "manage_credentials") await engine.set_risk(body, author=principal.user_id) - return engine.risk.config + return engine.order_risk_checks.config @router.post("/halt", tags=["risk"], summary="Stop trading", responses=RESPONSES) async def halt(principal: Caller, engine: EngineDep, body: HaltRequest) -> HaltResult: @@ -864,7 +864,7 @@ async def resume(principal: Caller, engine: EngineDep, scope: Annotated[str | None, Query()] = None) -> HaltState: require(principal, "trade") await engine.resume(scope=scope) - return HaltState(halted=engine.risk.kill.engaged, halt_reason=engine.risk.kill.reason) + return HaltState(halted=engine.order_risk_checks.kill.engaged, halt_reason=engine.order_risk_checks.kill.reason) # -- members, keys and the audit log -------------------------------------- diff --git a/tests/test_cancel_after_fill.py b/tests/test_cancel_after_fill.py index f718c94..595be77 100644 --- a/tests/test_cancel_after_fill.py +++ b/tests/test_cancel_after_fill.py @@ -162,7 +162,7 @@ async def test_cancel_after_a_stop_limit_child_filled_closes_the_parent(engine_c parent = await engine.submit(OrderRequest(market_id=MARKET, side="buy", quantity="2", type="stop_limit", stop_price="0.45", price="0.46", client_order_id="stop-limit-1")) await feed(engine, "0.44", "0.46") - child_id = engine.orders.get(parent.id).children[0].order_id + child_id = engine.managed_order_controller.get(parent.id).children[0].order_id assert venue.orders[child_id]["status"] == "resting" venue.fill(child_id) # the resting child fills; no stream tells the engine @@ -192,7 +192,7 @@ async def test_a_filled_stop_market_stays_closed_when_cancelled_before_and_after restarted = await started(venue, config, credentials) try: - assert restarted.orders.get(parent.id) is None, "a finished parent is not restored" + assert restarted.managed_order_controller.get(parent.id) is None, "a finished parent is not restored" result = await restarted.cancel(parent.id) assert result.status == OrderStatus.CLOSED and result.filled == D("2") stored = await restarted.journal.order("kalshi", parent.id) diff --git a/tests/test_engine.py b/tests/test_engine.py index f64c705..0296f49 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -387,7 +387,7 @@ async def test_each_rule_refuses_before_anything_is_sent(self, journal_path, rul async def test_the_price_collar_uses_the_book_midpoint(self, journal_path): venue = ScriptedVenue() engine = await engine_for(journal_path, venue) - engine.risk.config = engine.risk.config.model_copy(update={"price_collar": D("0.05")}) + engine.order_risk_checks.config = engine.order_risk_checks.config.model_copy(update={"price_collar": D("0.05")}) engine.set_book("kalshi:KX-A", LocalBook()) engine.books["kalshi:KX-A"].replace([(D("0.59"), D("10"))], [(D("0.61"), D("10"))]) with pytest.raises(RiskRejected) as caught: @@ -425,7 +425,7 @@ async def test_a_paused_book_may_cancel_but_not_open(self, journal_path): async def test_the_risk_configuration_is_versioned(self, journal_path): venue = ScriptedVenue() engine = await engine_for(journal_path, venue) - first = engine.risk.config_version + first = engine.order_risk_checks.config_version second = await engine.set_risk(RiskConfig(max_order_contracts=D("2"), price_collar=None, duplicate_window_ms=0)) assert second > first with pytest.raises(RiskRejected): diff --git a/tests/test_engine_orders.py b/tests/test_engine_orders.py index 58a3e3c..fe534ba 100644 --- a/tests/test_engine_orders.py +++ b/tests/test_engine_orders.py @@ -106,11 +106,11 @@ async def test_a_sell_stop_watches_the_bid_and_fires_once(self, setup): book(paper, engine, bids=[(D("0.42"), D("500"))], asks=[(D("0.44"), D("500"))]) await feed_book(engine, paper=paper) - assert engine.orders.get(parent.id).state == "waiting", "0.42 is above the stop" + assert engine.managed_order_controller.get(parent.id).state == "waiting", "0.42 is above the stop" book(paper, engine, bids=[(D("0.39"), D("500"))], asks=[(D("0.41"), D("500"))]) await feed_book(engine, paper=paper) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state in ("working", "done") and managed.triggered_at is not None assert managed.filled == D("10"), "the child took the bid" assert [f.side for f in paper.fills] == [Side.SELL] @@ -126,7 +126,7 @@ async def test_a_buy_stop_watches_the_ask(self, setup): parent = await engine.submit(request(type=OrderType.STOP_LIMIT, stop_price=D("0.50"), price=D("0.52"))) book(paper, engine, bids=[(D("0.48"), D("100"))], asks=[(D("0.51"), D("100"))]) await feed_book(engine, paper=paper) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "done" and managed.filled == D("10") assert paper.fills[0].price == D("0.51"), "a stop-limit takes what is inside its limit" @@ -136,16 +136,16 @@ async def test_the_trigger_source_can_be_the_last_trade(self, setup): parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), trigger_source="last")) await feed_book(engine, paper=paper) - assert engine.orders.get(parent.id).state == "waiting", "a wide book must not fire a last-trade stop" + assert engine.managed_order_controller.get(parent.id).state == "waiting", "a wide book must not fire a last-trade stop" await engine.on_trade({"market_id": SYM, "price": "0.38", "quantity": "5"}) - assert engine.orders.get(parent.id).state in ("working", "done") + assert engine.managed_order_controller.get(parent.id).state in ("working", "done") async def test_a_trailing_stop_follows_one_way_only(self, setup): engine, paper, clock = setup book(paper, engine, bids=[(D("0.50"), D("500"))], asks=[(D("0.52"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.TRAILING_STOP, stop_price=D("0.47"), params={"trail": "0.03"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) await feed_book(engine, paper=paper) assert managed.stop_price == D("0.47") @@ -166,7 +166,7 @@ async def test_a_stop_with_no_book_to_price_against_is_rejected(self, setup): parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), trigger_source="last")) await engine.on_trade({"market_id": SYM, "price": "0.35", "quantity": "1"}) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "rejected" and "no book" in managed.detail assert managed.as_order().status == OrderStatus.REJECTED and paper.fills == [] @@ -188,7 +188,7 @@ async def record(req): assert child.type == OrderType.MARKET and child.time_in_force == TimeInForce.IOC assert child.price == D("0.99"), "the ask plus 0.05, kept inside the price range" assert child.quantity == D("10") and child.tags["parent"] == parent.id - assert engine.orders.get(parent.id).as_order().status == OrderStatus.CLOSED + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.CLOSED async def test_an_ioc_remainder_ends_the_stop_with_the_unfilled_recorded(self, setup): engine, paper, clock = setup @@ -196,7 +196,7 @@ async def test_an_ioc_remainder_ends_the_stop_with_the_unfilled_recorded(self, s parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"))) book(paper, engine, bids=[(D("0.39"), D("4"))], asks=[(D("0.41"), D("500"))]) await feed_book(engine, paper=paper) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("4"), "only four contracts were available" assert not managed.live and "6 unfilled" in managed.detail assert managed.as_order().remaining == D("6") @@ -205,8 +205,8 @@ async def test_a_waiting_stop_can_be_cancelled(self, setup): engine, paper, clock = setup book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"))) - await engine.orders.cancel(parent.id) - managed = engine.orders.get(parent.id) + await engine.managed_order_controller.cancel(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.as_order().status == OrderStatus.CANCELED and managed.children == [] async def test_a_gtd_stop_market_expires_while_armed_and_sends_nothing(self, setup): @@ -215,11 +215,11 @@ async def test_a_gtd_stop_market_expires_while_armed_and_sends_nothing(self, set expires = int((clock() + 60) * 1000) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), time_in_force=TimeInForce.GTD, expires_at=expires)) - await engine.orders.on_timer() - assert engine.orders.get(parent.id).state == "waiting" + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).state == "waiting" clock.tick(61) - await engine.orders.on_timer() - managed = engine.orders.get(parent.id) + await engine.managed_order_controller.on_timer() + managed = engine.managed_order_controller.get(parent.id) assert managed.as_order().status == OrderStatus.EXPIRED and managed.children == [] and paper.fills == [] async def test_a_day_stop_market_stays_armed_until_the_session_end(self, setup): @@ -227,7 +227,7 @@ async def test_a_day_stop_market_stays_armed_until_the_session_end(self, setup): book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), time_in_force=TimeInForce.DAY)) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.request.expires_at == session_expiry(clock(), session_end=engine.config.session_end, timezone_name=engine.config.session_timezone) @@ -239,8 +239,8 @@ async def test_a_triggered_stop_market_is_not_cut_short_by_its_expiry(self, setu book(paper, engine, bids=[(D("0.39"), D("500"))], asks=[(D("0.41"), D("500"))]) await feed_book(engine, paper=paper) clock.tick(61) - await engine.orders.on_timer() - assert engine.orders.get(parent.id).as_order().status == OrderStatus.CLOSED + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.CLOSED async def test_a_stop_limit_child_carries_the_orders_time_in_force(self, setup): engine, paper, clock = setup @@ -261,11 +261,11 @@ async def record(req): [child] = sent assert (child.type, child.price, child.time_in_force, child.expires_at) == ( OrderType.LIMIT, D("0.38"), TimeInForce.GTD, expires) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.as_order().status == OrderStatus.TRIGGERED and len(managed.live_children) == 1 clock.tick(61) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.as_order().status == OrderStatus.EXPIRED assert await paper.fetch_open_orders() == [], "the resting child was pulled" @@ -282,7 +282,7 @@ async def record(req): book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_LIMIT, stop_price=D("0.40"), price=D("0.38"), time_in_force=TimeInForce.DAY)) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) session_end = session_expiry(clock(), session_end=engine.config.session_end, timezone_name=engine.config.session_timezone) assert (managed.request.time_in_force, managed.request.expires_at) == (TimeInForce.GTD, session_end), \ @@ -293,7 +293,7 @@ async def record(req): assert (child.type, child.time_in_force, child.expires_at) == (OrderType.LIMIT, TimeInForce.GTD, session_end) clock.t = session_end / 1000 + 1 - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.as_order().status == OrderStatus.EXPIRED assert await paper.fetch_open_orders() == [], "the resting child was canceled at the session end" @@ -302,7 +302,7 @@ async def test_a_stop_already_met_by_the_book_the_engine_holds_triggers_at_submi book(paper, engine, bids=[(D("0.39"), D("500"))], asks=[(D("0.41"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"))) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.triggered_at is not None and managed.filled == D("10"), "no further book event was needed" async def test_a_gtd_stop_limit_expires_before_it_triggers(self, setup): @@ -311,8 +311,8 @@ async def test_a_gtd_stop_limit_expires_before_it_triggers(self, setup): parent = await engine.submit(request(type=OrderType.STOP_LIMIT, stop_price=D("0.50"), price=D("0.52"), time_in_force=TimeInForce.GTD, expires_at=int((clock() + 5) * 1000))) clock.tick(6) - await engine.orders.on_timer() - assert engine.orders.get(parent.id).as_order().status == OrderStatus.EXPIRED + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.EXPIRED async def test_a_stop_limit_cancelled_after_the_trigger_pulls_its_child(self, setup): engine, paper, clock = setup @@ -321,8 +321,8 @@ async def test_a_stop_limit_cancelled_after_the_trigger_pulls_its_child(self, se price=D("0.38"))) book(paper, engine, bids=[(D("0.39"), D("0"))], asks=[(D("0.50"), D("500"))]) await feed_book(engine, paper=paper) - await engine.orders.cancel(parent.id) - assert engine.orders.get(parent.id).as_order().status == OrderStatus.CANCELED + await engine.managed_order_controller.cancel(parent.id) + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.CANCELED assert await paper.fetch_open_orders() == [] async def test_an_older_immediate_stop_limit_is_still_restored(self, setup): @@ -334,7 +334,7 @@ async def test_an_older_immediate_stop_limit_is_still_restored(self, setup): engine, paper, clock = setup parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_LIMIT, stop_price=D("0.40"), price=D("0.38"))) - snapshot = engine.orders.get(parent.id).snapshot() + snapshot = engine.managed_order_controller.get(parent.id).snapshot() snapshot["request"]["time_in_force"] = "ioc" with pytest.raises(ValidationError): OrderRequest.model_validate(snapshot["request"]) @@ -346,7 +346,7 @@ async def test_the_trigger_source_survives_a_restart_of_the_parent(self, setup): engine, paper, clock = setup parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), trigger_source="mid")) - snapshot = engine.orders.get(parent.id).snapshot() + snapshot = engine.managed_order_controller.get(parent.id).snapshot() assert snapshot["request"]["trigger_source"] == "mid" assert StopMarket.from_snapshot(snapshot).trigger_source == "mid" @@ -399,22 +399,22 @@ async def test_it_shows_a_slice_and_reloads_after_the_delay(self, setup): book(paper, engine, bids=[(D("0.40"), D("0"))], asks=[(D("0.45"), D("100"))]) parent = await engine.submit(request(quantity=D("30"), price=D("0.40"), type=OrderType.ICEBERG, params={"display": "10", "reload_delay_s": 5})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert len(managed.children) == 1 and managed.children[0].quantity == D("10") assert await paper.fetch_open_orders() and (await paper.fetch_open_orders())[0].quantity == D("10") await tape(paper, price=D("0.40"), quantity=D("10"), taker_side=Side.SELL) assert managed.filled == D("10") and len(managed.live_children) == 0 - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert len(managed.children) == 1, "the reload waits for its delay" clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert len(managed.children) == 2 and managed.reloads == 1 await tape(paper, price=D("0.40"), quantity=D("10"), taker_side=Side.SELL) clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await tape(paper, price=D("0.40"), quantity=D("10"), taker_side=Side.SELL) assert managed.filled == D("30") and managed.state == "done" @@ -440,7 +440,7 @@ async def test_one_leg_filling_cancels_the_other(self, setup): {"type": "limit", "side": "buy", "price": "0.29"}]}, )) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert len(managed.live_children) == 2 first = managed.children[0] await tape(paper, price=D("0.31"), quantity=D("10"), taker_side=Side.SELL) @@ -455,7 +455,7 @@ async def test_a_partial_fill_resizes_the_other_leg(self, setup): params={"legs": [{"type": "limit", "side": "buy", "price": "0.31"}, {"type": "limit", "side": "buy", "price": "0.29"}]}, )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) await tape(paper, price=D("0.31"), quantity=D("4"), taker_side=Side.SELL) assert managed.filled == D("4") others = [o for o in await paper.fetch_open_orders() if o.price == D("0.29")] @@ -470,14 +470,14 @@ async def test_a_bracket_protects_what_the_entry_fills(self, setup): "take_profit": {"type": "limit", "price": "0.55"}, "stop_loss": {"type": "stop_market", "stop_price": "0.35"}}, )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.entered == D("0") and len(managed.live_children) == 1 await tape(paper, price=D("0.41"), quantity=D("6"), taker_side=Side.SELL) assert managed.entered == D("6") and managed.protected == D("6") protection = [c for c in managed.children if managed.leg_of.get(c.order_id) in ("take_profit", "stop_loss")] assert [c.quantity for c in protection] == [D("6"), D("6")] - assert engine.orders.get(protection[1].order_id).kind == "stop_market", "the stop-loss is its own managed order" + assert engine.managed_order_controller.get(protection[1].order_id).kind == "stop_market", "the stop-loss is its own managed order" await tape(paper, price=D("0.41"), quantity=D("4"), taker_side=Side.SELL) assert managed.entered == D("10") and managed.protected == D("10") @@ -489,7 +489,7 @@ async def test_a_bracket_whose_entry_never_fills_ends_with_it(self, setup): quantity=D("10"), type=OrderType.BRACKET, params={"entry": {"type": "limit", "price": "0.20"}, "take_profit": {"type": "limit", "price": "0.55"}}, )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) entry = managed.leg_children("entry")[0] await engine.cancel(entry.order_id) assert managed.state == "canceled" and "entry" in managed.detail @@ -505,15 +505,15 @@ async def test_it_slices_across_the_window(self, setup): book(paper, engine, bids=[(D("0.40"), D("100"))], asks=[(D("0.42"), D("100"))]) parent = await engine.submit(request(quantity=D("10"), price=D("0.41"), type=OrderType.TWAP, params={"window_s": 100, "slices": 5, "style": "limit"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.sent_slices == 1 and managed.children[0].quantity == D("2") clock.tick(21) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.sent_slices == 2 and len(managed.children) == 2 clock.tick(60) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.sent_slices == 5, "a late tick catches up rather than skipping" async def test_the_window_ends_by_taking_the_rest(self, setup): @@ -523,10 +523,10 @@ async def test_the_window_ends_by_taking_the_rest(self, setup): params={"window_s": 60, "slices": 3, "style": "taker", "limit": "0.45", "finish": "complete"})) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled > 0, "a taker slice crosses at once" clock.tick(61) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.filled == D("10") and managed.state == "done" @@ -536,9 +536,9 @@ async def test_finish_stop_leaves_the_rest_undone(self, setup): parent = await engine.submit(request(quantity=D("10"), price=D("0.10"), type=OrderType.TWAP, params={"window_s": 30, "slices": 3, "style": "limit", "limit": "0.10", "finish": "stop"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) clock.tick(31) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.state in ("canceled", "done") and managed.filled == D("0") assert await paper.fetch_open_orders() == [] @@ -554,7 +554,7 @@ async def test_it_follows_the_touch_within_its_bounds(self, setup): parent = await engine.submit(request(quantity=D("10"), price=D("0.40"), type=OrderType.PEG, params={"reference": "near", "offset": "0", "min_stay_s": 0, "max_price": "0.45", "level_cap": 3})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.resting_price == D("0.40") book(paper, engine, bids=[(D("0.41"), D("100"))], asks=[(D("0.44"), D("100"))]) @@ -571,7 +571,7 @@ async def test_the_minimum_stay_and_the_level_cap_hold_it_still(self, setup): book(paper, engine, bids=[(D("0.40"), D("100"))], asks=[(D("0.44"), D("100"))]) parent = await engine.submit(request(quantity=D("10"), price=D("0.40"), type=OrderType.PEG, params={"reference": "near", "min_stay_s": 10, "level_cap": 2})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) book(paper, engine, bids=[(D("0.41"), D("100"))], asks=[(D("0.44"), D("100"))]) await feed_book(engine, paper=paper) assert managed.resting_price == D("0.40"), "it has not sat long enough to move" @@ -600,7 +600,7 @@ async def test_a_walked_market_order_stops_at_the_slippage_bound(self, setup): parent = await engine.submit(request(quantity=D("20"), type=OrderType.MARKET, params={"walk": True, "max_slippage": "0.03"})) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("6"), "0.42 and 0.44 are inside 0.45; 0.60 is not" assert managed.state in ("done", "canceled") and "unfilled" in managed.detail @@ -610,17 +610,17 @@ async def test_a_smart_taker_clips_over_time(self, setup): parent = await engine.submit(request(quantity=D("10"), type=OrderType.SMART_TAKER, params={"clip": "4", "interval_s": 5, "limit": "0.45"})) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("4") and managed.clips == 1 - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.clips == 1, "the interval has not passed" clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.clips == 2 and managed.filled == D("8") clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.filled == D("10") and managed.state == "done" @@ -629,11 +629,11 @@ async def test_a_smart_taker_waits_rather_than_paying_up(self, setup): book(paper, engine, bids=[], asks=[(D("0.60"), D("100"))]) parent = await engine.submit(request(quantity=D("10"), type=OrderType.SMART_TAKER, params={"clip": "5", "interval_s": 1, "limit": "0.45"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("0") and managed.state == "working" book(paper, engine, bids=[], asks=[(D("0.44"), D("100"))]) clock.tick(2) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.filled == D("5") @@ -671,7 +671,7 @@ async def test_a_restart_brings_the_parents_back(self, setup, tmp_path): params={"trail": "0.03"})) book(paper, engine, bids=[(D("0.60"), D("500"))], asks=[(D("0.62"), D("500"))]) await feed_book(engine, paper=paper) - assert engine.orders.get(parent.id).stop_price == D("0.57") + assert engine.managed_order_controller.get(parent.id).stop_price == D("0.57") path = engine.config.journal_path await engine.journal.close() @@ -685,7 +685,7 @@ async def test_a_restart_brings_the_parents_back(self, setup, tmp_path): recovery = await restarted.start() try: assert recovery.managed == 1 - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) assert revived.stop_price == D("0.57"), "the trail resumes where the market left it" restarted.set_book(SYM, paper.books[SYM]) book(paper, restarted, bids=[(D("0.56"), D("500"))], asks=[(D("0.58"), D("500"))]) @@ -701,7 +701,7 @@ async def test_a_halt_stands_every_parent_down(self, setup): await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.20"))) result = await engine.halt("drill", policy="cancel") assert result["managed"] == 2 - assert all(not p.live for p in engine.orders.parents.values()) + assert all(not p.live for p in engine.managed_order_controller.parents.values()) assert await paper.fetch_open_orders() == [] async def test_cancelling_a_parent_pulls_its_children(self, setup): @@ -713,7 +713,7 @@ async def test_cancelling_a_parent_pulls_its_children(self, setup): canceled = await engine.cancel(parent.id) assert canceled.status == OrderStatus.CANCELED assert await paper.fetch_open_orders() == [] - assert engine.orders.get(parent.id).state == "canceled" + assert engine.managed_order_controller.get(parent.id).state == "canceled" async def test_parents_are_not_reported_as_ghosts(self, setup): """A parent is not an order at the venue, so reconciliation must not @@ -725,7 +725,7 @@ async def test_parents_are_not_reported_as_ghosts(self, setup): parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.10"))) report = (await Reconciler(engine).run("kalshi"))[0] assert [d.key for d in report.ghosts] == [] - assert engine.orders.get(parent.id).state == "waiting" + assert engine.managed_order_controller.get(parent.id).state == "waiting" class TestOrderFields: @@ -760,7 +760,7 @@ async def test_bracket_legs_inherit_the_outcome_and_action_unless_they_say_other parent = await engine.submit(request(side=Side.SELL, outcome="yes", action="sell", type=OrderType.OCO, params={"legs": [{"side": "sell", "type": "limit", "price": "0.60"}, {"side": "sell", "type": "limit", "price": "0.65"}]})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert leg_request({"type": "limit", "price": "0.6"}, parent=managed).leg == ("yes", "sell") assert leg_request({"type": "limit", "price": "0.6", "outcome": "no", "action": "buy"}, parent=managed).leg == ("no", "buy") @@ -793,12 +793,12 @@ async def test_a_parent_ends_at_its_expiry_and_pulls_its_children(self, setup): expires = int((clock() + 60) * 1000) parent = await engine.submit(request(quantity=D("30"), price=D("0.40"), type=OrderType.ICEBERG, expires_at=expires, params={"display": "10"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert len(managed.live_children) == 1 - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.live, "not yet" clock.tick(61) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.state == "expired" and not managed.live_children assert managed.as_order().status == OrderStatus.EXPIRED assert await paper.fetch_open_orders() == [] diff --git a/tests/test_engine_orders_demo.py b/tests/test_engine_orders_demo.py index 5b052da..4f7aba7 100644 --- a/tests/test_engine_orders_demo.py +++ b/tests/test_engine_orders_demo.py @@ -141,7 +141,7 @@ async def test_an_iceberg_shows_only_its_slice(self, engine, kalshi, market): )) assert parent.status == OrderStatus.TRIGGERED and parent.held_by.value == "engine" - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) mine = {c.order_id for c in managed.children} assert len(mine) == 1, "one slice at a time" @@ -170,7 +170,7 @@ async def test_a_stop_watches_the_real_book_and_does_not_fire_early(self, engine stop_price=(bid / 2).quantize(D("0.01")), book="demo-stop", )) await engine.on_book({"instrument_id": f"{market}:yes"}) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "waiting", "the stop is far from the touch" assert [o for o in await kalshi.fetch_open_orders() if o.market_id == market] == [] @@ -189,7 +189,7 @@ async def test_a_twap_sends_its_slices_to_the_venue(self, engine, kalshi, market instrument_id=f"{market}:yes", side=Side.BUY, quantity=D("2"), price=D("0.02"), type=OrderType.TWAP, params={"window_s": 4, "slices": 2, "style": "limit", "limit": "0.02"}, book="demo-twap", )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.sent_slices == 1 assert await wait_for(lambda: len(managed.children) >= 2, timeout=10) @@ -214,7 +214,7 @@ async def test_a_parent_survives_a_restart_against_the_venue(self, tmp_path, kal instrument_id=f"{market}:yes", side=Side.BUY, quantity=D("2"), price=D("0.02"), type=OrderType.ICEBERG, params={"display": "1"}, book="demo-restart", )) - child_ids = [c.order_id for c in first.orders.get(parent.id).children] + child_ids = [c.order_id for c in first.managed_order_controller.get(parent.id).children] await first.journal.close() second = Engine({"kalshi": kalshi}, EngineConfig(journal_path=path, managed_tick_s=0.05), @@ -223,7 +223,7 @@ async def test_a_parent_survives_a_restart_against_the_venue(self, tmp_path, kal recovery = await second.start() try: assert recovery.managed == 1 - revived = second.orders.get(parent.id) + revived = second.managed_order_controller.get(parent.id) assert revived is not None and revived.state == "working" assert [c.order_id for c in revived.children] == child_ids, "the same child, not a new one" async def child_is_live() -> bool: @@ -243,7 +243,7 @@ async def test_a_halt_stands_parents_and_children_down(self, engine, kalshi, mar )) result = await engine.halt("demo drill", policy="cancel") assert result["managed"] >= 1 - assert all(not p.live for p in engine.orders.parents.values()) + assert all(not p.live for p in engine.managed_order_controller.parents.values()) assert await wait_for(lambda: no_orders_left(kalshi, market)) async def test_a_day_order_carries_a_venue_expiry(self, engine, kalshi, market): diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 2ad2558..485c58a 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -158,7 +158,7 @@ async def test_a_stop_fires_from_a_streamed_book_and_not_before_the_snapshot(sel kalshi.set_book(K, bids=[(D("0.38"), D("100"))]) # where the child will actually trade parent = await engine.submit(stop_request()) await feeds.sync() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) await feeds.dispatch("kalshi", k_stream, BookEvent(venue="kalshi", market_id=K, kind="delta")) assert managed.state == "waiting", "no snapshot yet: the book is not ready and reads as no book" @@ -176,7 +176,7 @@ async def test_a_book_invalidated_after_a_gap_does_not_fire_a_stop(self, setup): k_stream.books[K].bids = {D("0.30"): D("100")} # stale levels left behind by a gap k_stream.books[K].invalidate() await feeds.dispatch("kalshi", k_stream, BookEvent(venue="kalshi", market_id=K, kind="delta")) - assert engine.orders.get(parent.id).state == "waiting" + assert engine.managed_order_controller.get(parent.id).state == "waiting" class TestMarks: @@ -246,7 +246,7 @@ async def test_an_order_update_on_the_private_stream_completes_the_parent(self, parent = await engine.submit(stop_request()) await feeds.sync() await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.39", "100")], asks=[("0.41", "100")])) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) child = managed.children[0] venue_order = kalshi.orders[child.order_id] assert managed.filled == venue_order.filled, "the placement's answer is the first record" @@ -261,7 +261,7 @@ async def test_a_fill_event_books_in_the_ledger_and_leaves_the_parent_alone(self await feeds.sync() kalshi.set_book(K, bids=[]) # nothing to cross: the child rests await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.39", "100")], asks=[("0.41", "100")])) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) child = managed.children[0] before = managed.filled fill = Fill(id="f-1", order_id=child.order_id, venue="kalshi", account=ACCOUNTS["kalshi"], market_id=K, diff --git a/tests/test_order_storage.py b/tests/test_order_storage.py index 4ac4961..bbbedd0 100644 --- a/tests/test_order_storage.py +++ b/tests/test_order_storage.py @@ -109,7 +109,7 @@ async def test_configured_owner_survives_parent_restart_and_is_inherited_by_chil restarted = Engine({"kalshi": venue}, config, risk=risk) try: await restarted.start() - restored = restarted.orders.get(parent.id) + restored = restarted.managed_order_controller.get(parent.id) assert restored.request.user_id == "environment-owner" and restored.state == "waiting" assert restored.child_request(quantity=D("1"), price=D("0.3")).user_id == "environment-owner" finally: diff --git a/tests/test_polymarket_stop_lifecycle.py b/tests/test_polymarket_stop_lifecycle.py index 86777a7..191681a 100644 --- a/tests/test_polymarket_stop_lifecycle.py +++ b/tests/test_polymarket_stop_lifecycle.py @@ -57,7 +57,7 @@ async def test_waiting_restart_limit_child_direction_and_targeted_cancel(venue, assert (await engine.submit(request)).id == parent.id engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.46"))) await engine.on_book({"market_id": MARKET}) - assert engine.orders.get(parent.id).as_order().status == OrderStatus.TRIGGERED + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.TRIGGERED sent = venue.sent("POST", "/order") assert len(sent) == 1 body = json.loads(sent[0].content) @@ -92,7 +92,7 @@ async def test_stop_market_child_uses_fak_and_remains_pending_until_fill_readbac stop_price="0.40", client_order_id="market-a")) engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.41"))) await engine.on_book({"market_id": MARKET}) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.as_order().status == OrderStatus.TRIGGERED and managed.filled == 0 sent = json.loads(venue.sent("POST", "/order")[0].content) assert sent["orderType"] == "FAK" and sent["order"]["tokenId"] == token_id @@ -123,12 +123,12 @@ def lose_response(request): parent = await engine.submit(req) engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.41"))) await engine.on_book({"market_id": MARKET}) - assert engine.orders.get(parent.id).pending_child is not None + assert engine.managed_order_controller.get(parent.id).pending_child is not None await engine.stop() engine = engine_for(poly, path) recovery = await engine.start() assert recovery.unresolved == 1 - restored = engine.orders.get(parent.id) + restored = engine.managed_order_controller.get(parent.id) assert restored.pending_child is not None and restored.children == [] assert (await engine.submit(req)).id == parent.id assert len(venue.sent("POST", "/order")) == 1 diff --git a/tests/test_postgres_storage.py b/tests/test_postgres_storage.py index 9f318fc..7c07958 100644 --- a/tests/test_postgres_storage.py +++ b/tests/test_postgres_storage.py @@ -206,10 +206,10 @@ async def exit_after_response(order, **kwargs): venue = lifecycle_scenarios.DurableFakeVenue(venue_path) engine, _ = await lifecycle_scenarios.scripted_engine(dsn, venue, storage_backend="postgresql", storage_schema=schema) try: - parent = engine.orders.live()[0] + parent = engine.managed_order_controller.live()[0] assert len(parent.children) == 1 and parent.pending_child is None for _ in range(2): - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert len(venue.orders) == 1 assert len(venue.sent) == (1 if boundary == "before_send" else 0) finally: @@ -404,7 +404,7 @@ async def test_audit_failure_rolls_back_submission_before_dispatch(database): with pytest.raises(psycopg.errors.RaiseException, match="simulated audit failure"): await engine.submit(lifecycle_scenarios.stop_request()) assert await engine.journal.submission("protect-a") is None - assert engine.orders.parents == {} and venue.sent == [] + assert engine.managed_order_controller.parents == {} and venue.sent == [] finally: await engine.stop() diff --git a/tests/test_risk_duplicates.py b/tests/test_risk_duplicates.py index 28800d4..86e9fda 100644 --- a/tests/test_risk_duplicates.py +++ b/tests/test_risk_duplicates.py @@ -28,15 +28,15 @@ def risk() -> RiskEngine: def test_the_same_order_from_outside_is_refused_within_the_window(): - decision = risk().check(req(), venue="kalshi", account=ACCOUNT) + decision = risk().evaluate_order_risk(req(), venue="kalshi", account=ACCOUNT) assert not decision.ok and decision.rule == "duplicate" def test_an_engine_child_that_looks_the_same_is_not_a_duplicate(): - decision = risk().check(req(tags={"parent": "mo-2"}), venue="kalshi", account=ACCOUNT) + decision = risk().evaluate_order_risk(req(tags={"parent": "mo-2"}), venue="kalshi", account=ACCOUNT) assert decision.ok, decision.message def test_a_caller_who_names_the_client_order_id_is_not_clicking_twice(): - decision = risk().check(req(client_order_id="mine-2"), venue="kalshi", account=ACCOUNT, deliberate=True) + decision = risk().evaluate_order_risk(req(client_order_id="mine-2"), venue="kalshi", account=ACCOUNT, caller_supplied_order_id=True) assert decision.ok, decision.message diff --git a/tests/test_routed_limit.py b/tests/test_routed_limit.py index ded9c1a..75a81c8 100644 --- a/tests/test_routed_limit.py +++ b/tests/test_routed_limit.py @@ -95,7 +95,7 @@ async def test_legs_land_on_both_venues_and_the_parent_reports_in_bucket_terms(s assert [(o.side, o.price, o.quantity) for o in poly.orders.values()] == [(Side.BUY, D("0.42"), D("20"))] await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "done" and managed.filled == D("50") assert managed.as_order().average_price == (D("0.41") * 30 + D("0.42") * 20) / 50 report = managed.report() @@ -126,7 +126,7 @@ async def test_a_flipped_member_is_sold_at_one_minus(self, tmp_path): assert [(o.side, o.price, o.quantity) for o in poly.orders.values()] == [(Side.SELL, D("0.70"), D("40"))] assert kalshi.orders == {} await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "done" and managed.as_order().average_price == D("0.30") finally: await engine.stop() @@ -140,7 +140,7 @@ async def test_a_member_switched_off_gets_no_leg(self, setup): parent = await engine.submit(request(off)) assert [(o.price, o.quantity) for o in kalshi.orders.values()] == [(D("0.44"), D("30"))] assert poly.orders == {}, "the cheaper member is off, so nothing goes there" - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.markets() == [K] and managed.venues() == {"kalshi"} @@ -152,7 +152,7 @@ async def test_a_partial_fill_leaves_the_remainder_resting_and_the_rest_complete kalshi.set_book(K, asks=[(D("0.41"), D("10"))]) # someone else took 20 before our leg arrived parent = await engine.submit(request(bucket)) await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "working" and managed.filled == D("30") resting = open_orders(kalshi) assert [(o.price, o.quantity, o.filled) for o in resting] == [(D("0.41"), D("30"), D("10"))] @@ -174,7 +174,7 @@ async def test_only_the_leg_whose_price_changed_is_replaced(self, setup): poly.set_book(P, asks=[(D("0.50"), D("100"))]) parent = await engine.submit(request(bucket)) await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) first_k, first_p = list(kalshi.orders)[0], list(poly.orders)[0] engine.set_book(K, BookState(asks=[(D("0.40"), D("30"))])) @@ -211,7 +211,7 @@ async def test_nothing_inside_the_limit_stops_it_and_says_so(self, setup): book(kalshi, engine, K, asks=[(D("0.50"), D("100"))]) book(poly, engine, P, asks=[(D("0.52"), D("100"))]) parent = await engine.submit(request(bucket)) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert parent.status == OrderStatus.CANCELED assert managed.stop_reason == "worst_price" and managed.report()["unfilled"] == "50" assert kalshi.orders == {} and poly.orders == {} @@ -225,7 +225,7 @@ async def test_max_rounds_stops_it_with_a_partial(self, setup): # A fill re-plans but changes no leg, so it is not a round; the book move is round 1. parent = await engine.submit(request(bucket, params={"max_rounds": 1})) await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("10") and managed.state == "working" and managed.rounds == 0 engine.set_book(K, BookState(asks=[(D("0.40"), D("30"))])) await engine.on_book({"market_id": K}) @@ -241,9 +241,9 @@ async def test_cancelling_the_parent_pulls_every_leg_on_every_venue(self, setup) poly.set_book(P, asks=[(D("0.50"), D("100"))]) parent = await engine.submit(request(bucket)) assert open_orders(kalshi) and open_orders(poly) - await engine.orders.cancel(parent.id) + await engine.managed_order_controller.cancel(parent.id) assert open_orders(kalshi) == [] and open_orders(poly) == [] - assert engine.orders.get(parent.id).state == "canceled" + assert engine.managed_order_controller.get(parent.id).state == "canceled" async def test_a_halt_on_one_member_venue_reaches_it(self, setup): engine, kalshi, poly, clock, bucket = setup @@ -252,10 +252,10 @@ async def test_a_halt_on_one_member_venue_reaches_it(self, setup): kalshi.set_book(K, asks=[(D("0.50"), D("100"))]) poly.set_book(P, asks=[(D("0.50"), D("100"))]) parent = await engine.submit(request(bucket)) - halted = await engine.orders.on_halt("test", scope="polymarket") + halted = await engine.managed_order_controller.on_halt("test", scope="polymarket") assert halted == 1 assert open_orders(kalshi) == [] and open_orders(poly) == [] - assert engine.orders.get(parent.id).state == "canceled" + assert engine.managed_order_controller.get(parent.id).state == "canceled" class TestPosition: @@ -330,7 +330,7 @@ async def test_the_curve_is_read_under_the_floor_and_a_small_leg_goes_elsewhere( book(poly, engine, P, asks=[(D("0.400"), D("10"))]) parent = await engine.submit(request(bucket, quantity=D("40"))) assert asked[:2] == [D("100"), D("10000")] - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.fee_cache[f"{P}@0.400"] == D("0.0001") and managed.fee_floors[P] == D("0.25") # Polymarket's 10 are 0.2c cheaper but pay 0.25 in fees: Kalshi takes all 40. assert poly.orders == {} and [o.quantity for o in kalshi.orders.values()] == [D("40")] diff --git a/tests/test_routed_recovery.py b/tests/test_routed_recovery.py index 6c78515..f06f539 100644 --- a/tests/test_routed_recovery.py +++ b/tests/test_routed_recovery.py @@ -87,10 +87,10 @@ async def test_a_fill_while_down_is_counted_once_and_nothing_is_placed_twice(sel recovery = await restarted.start() try: assert recovery.managed == 1 - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) assert revived.state == "working" and revived.known_filled == 0, "nothing is trusted before the venues are asked" - await restarted.orders.on_timer() # first event after the restart: resync + await restarted.managed_order_controller.on_timer() # first event after the restart: resync assert revived.known_filled == D("30") assert revived.as_order().filled == D("30") and revived.report()["per_venue"] assert len(kalshi.orders) == 1 and len(poly.orders) == 1, "the fill was learned, not bought again" @@ -115,13 +115,13 @@ async def test_a_leg_cancelled_while_down_is_re_placed_within_the_remainder(self restarted = new_engine(path, kalshi, poly, clock) await restarted.start() try: - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) restarted.set_book(K, kalshi.books[K]) restarted.set_book(P, poly.books[P]) kalshi.set_book(K, asks=[(D("0.41"), D("100"))]) restarted.set_book(K, kalshi.books[K]) kalshi.set_book(K, asks=[(D("0.50"), D("100"))]) - await restarted.orders.on_timer() + await restarted.managed_order_controller.on_timer() live_k = open_orders(kalshi) assert len(live_k) == 1 and live_k[0].price == D("0.41") resting = sum(o.quantity - o.filled for o in open_orders(kalshi) + open_orders(poly)) @@ -145,12 +145,12 @@ async def test_a_child_the_snapshot_missed_is_adopted_from_the_journal(self, tmp restarted = new_engine(path, kalshi, poly, clock) await restarted.start() try: - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) assert len(revived.children) == 2 - await restarted.orders.on_timer() + await restarted.managed_order_controller.on_timer() assert len(revived.children) == 3 and revived.child_of(stray.id, "kalshi") is not None assert sum(c.quantity for c in revived.live_children) <= D("50") - assert restarted.orders.parent_of(stray.id, "kalshi") is revived + assert restarted.managed_order_controller.parent_of(stray.id, "kalshi") is revived finally: await restarted.stop() @@ -164,7 +164,7 @@ async def test_a_finished_bucket_order_is_still_reported_after_a_restart(tmp_pat again = new_engine(path, kalshi, poly, clock) await again.start() try: - assert again.orders.get(parent.id) is None, "a finished parent is not reloaded as live" + assert again.managed_order_controller.get(parent.id) is None, "a finished parent is not reloaded as live" bucket_id = parent.market_id.split(":", 1)[1] found = await again.bucket_orders(bucket_id) assert [p.id for p in found] == [parent.id] diff --git a/tests/test_serve_orders.py b/tests/test_serve_orders.py index b8cfa39..edcb146 100644 --- a/tests/test_serve_orders.py +++ b/tests/test_serve_orders.py @@ -182,7 +182,7 @@ def step(): harness.wait_until(lambda: K in harness.stack.feeds.watching["kalshi"]) harness.push("kalshi", stream.snapshot(K, bids=[("0.44", "5")], asks=[("0.46", "5")])) engine = harness.stack.engine - harness.wait_until(lambda: bool(engine.orders.get(placed["triggered"]["id"]).children)) + harness.wait_until(lambda: bool(engine.managed_order_controller.get(placed["triggered"]["id"]).children)) step() placed["elsewhere"] = place(harness, limit(market_id="kalshi:KXHARNESS-2", client_order_id="list-elsewhere")).json() @@ -193,7 +193,7 @@ def test_open_stays_the_default_and_all_shows_every_state_newest_first(self, ser with serve_harness(kalshi=kalshi) as harness: placed = self.place_one_of_everything(harness, kalshi) parent_id = placed["triggered"]["id"] - child_id = harness.stack.engine.orders.get(parent_id).children[0].order_id + child_id = harness.stack.engine.managed_order_controller.get(parent_id).children[0].order_id default = {row["id"]: row["status"] for row in orders(harness)["data"]} assert default == {placed["resting"]["id"]: "open", placed["waiting"]["id"]: "waiting", @@ -236,7 +236,7 @@ def test_a_finished_parent_is_listed_after_a_restart(self, serve_harness): "stop_price": "0.10", "client_order_id": "list-ended"}).json() harness.client.delete(f"/trading/orders/{parent['id']}", headers=harness.auth) engine = harness.stack.engine - harness.call(lambda: engine.orders.parents.pop(parent["id"])) # what a restart leaves + harness.call(lambda: engine.managed_order_controller.parents.pop(parent["id"])) # what a restart leaves rows = orders(harness, scope="all")["data"] assert [(row["id"], row["status"], row["held_by"]) for row in rows] == \ [(parent["id"], "canceled", "engine")] @@ -290,7 +290,7 @@ def screen_loaded(harness, socket, *, bid: str = "0.92", ask: str = "0.94") -> N @staticmethod def state(harness, parent_id: str) -> str: - return harness.stack.engine.orders.get(parent_id).state + return harness.stack.engine.managed_order_controller.get(parent_id).state def test_a_stop_already_met_on_a_book_the_screen_loaded_triggers_without_another_update(self, serve_harness): kalshi = MockKalshi() diff --git a/tests/test_server_trading.py b/tests/test_server_trading.py index 286150b..662b6ad 100644 --- a/tests/test_server_trading.py +++ b/tests/test_server_trading.py @@ -226,7 +226,7 @@ async def test_place_amend_cancel(self, server): async def test_a_risk_rule_answers_409_with_its_name(self, server): client, engine = server["client"], server["engine"] - engine.risk.config = engine.risk.config.model_copy(update={"max_order_contracts": D("1")}) + engine.order_risk_checks.config = engine.order_risk_checks.config.model_copy(update={"max_order_contracts": D("1")}) response = client.post("/orders", json=order_body(quantity="5"), headers=auth(server["trader_key"])) error = response.json()["error"] assert response.status_code == 409 and error["code"] == "risk_rejected" diff --git a/tests/test_stop_lifecycle.py b/tests/test_stop_lifecycle.py index 0bcf144..71dd80c 100644 --- a/tests/test_stop_lifecycle.py +++ b/tests/test_stop_lifecycle.py @@ -63,7 +63,7 @@ async def test_retry_same_stop_returns_original_parent_even_after_restart(tmp_pa engine, venue = await scripted_engine(path) original = await engine.submit(stop_request()) repeated = await engine.submit(stop_request(quantity="10.000", stop_price="0.400")) - assert repeated.id == original.id and len(engine.orders.parents) == 1 + assert repeated.id == original.id and len(engine.managed_order_controller.parents) == 1 await engine.stop() restarted, _ = await scripted_engine(path, venue) try: @@ -123,12 +123,12 @@ async def test_day_retry_keeps_the_original_expiry(setup): engine, paper, clock = setup req = request(side="sell", type="stop_market", stop_price="0.40", time_in_force="day", client_order_id="day-a") first = await engine.submit(req) - expiry = engine.orders.get(first.id).request.expires_at + expiry = engine.managed_order_controller.get(first.id).request.expires_at clock.tick(86400) repeated = await engine.submit(req) assert repeated.id == first.id and repeated.expires_at == expiry - await engine.orders.on_timer() - assert engine.orders.get(first.id).state == "expired" and paper.fills == [] + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(first.id).state == "expired" and paper.fills == [] async def test_expiry_wins_when_price_arrives_before_the_timer(setup): @@ -138,7 +138,7 @@ async def test_expiry_wins_when_price_arrives_before_the_timer(setup): clock.tick(1) book(paper, engine, bids=[(D("0.39"), D("100"))], asks=[(D("0.41"), D("100"))]) await feed_book(engine, paper=paper) - restored = engine.orders.get(parent.id) + restored = engine.managed_order_controller.get(parent.id) assert restored.state == "expired" and restored.children == [] @@ -163,7 +163,7 @@ async def inspect(request): async def test_atomic_plan_rolls_back_both_parent_and_child(setup, monkeypatch): engine, paper, clock = setup parent = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40")) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) snapshot = {**managed.snapshot(), "state": "working"} child = managed.child_request(quantity=D("10"), price=D("0.01"), type="market", time_in_force="ioc") child = child.model_copy(update={"client_order_id": "child-a"}) @@ -186,14 +186,14 @@ async def test_lost_child_response_stays_triggered_and_is_adopted_after_restart( parent = await engine.submit(stop_request()) venue.fail_next = "arrived" await cross(engine) - assert engine.orders.get(parent.id).state == "working" and len(venue.sent) == 1 + assert engine.managed_order_controller.get(parent.id).state == "working" and len(venue.sent) == 1 await engine.stop() restarted, _ = await scripted_engine(path, venue) try: - restored = restarted.orders.get(parent.id) + restored = restarted.managed_order_controller.get(parent.id) assert len(restored.children) == 1 and restored.pending_child is None for _ in range(3): - await restarted.orders.on_timer() + await restarted.managed_order_controller.on_timer() await cross(restarted) assert len(venue.sent) == 1 assert restored.children[0].order_id == "V1" @@ -206,7 +206,7 @@ async def test_stop_market_uses_deep_available_liquidity_and_reports_execution_a parent = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40")) book(paper, engine, bids=[(D("0.39"), D("4")), (D("0.20"), D("6"))], asks=[(D("0.41"), D("10"))]) await feed_book(engine, paper=paper) - result = engine.orders.get(parent.id).as_order() + result = engine.managed_order_controller.get(parent.id).as_order() assert result.status == OrderStatus.CLOSED and result.filled == D("10") assert result.average_price == D("0.276"), "the fill average is not the 0.01 child limit" child = (await engine.journal.orders_for_parent(parent.id))[0] @@ -229,7 +229,7 @@ async def test_cancel_and_trigger_race_leaves_no_working_child(tmp_path): parent = await engine.submit(stop_request(type="stop_limit", price="0.38")) try: await asyncio.wait_for(asyncio.gather(cross(engine), engine.cancel(parent.id)), 2) - assert engine.orders.get(parent.id).state == "canceled" + assert engine.managed_order_controller.get(parent.id).state == "canceled" assert await venue.fetch_open_orders() == [] assert len(venue.sent) <= 1 finally: @@ -248,8 +248,8 @@ async def acknowledge(order_id, **kwargs): result = await engine.cancel(parent.id) assert result.status == OrderStatus.PENDING_CANCEL venue.cancel_order = original - await engine.orders.on_timer() - assert engine.orders.get(parent.id).state == "canceled" + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).state == "canceled" finally: await engine.stop() @@ -267,9 +267,9 @@ async def history(**kwargs): return Page([], next_cursor="next") if kwargs.get("cursor") is None else Page([child]) venue.fetch_orders = history try: - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert cursors == [None, "next"] - assert engine.orders.get(parent.id).state == "done" and len(venue.sent) == 1 + assert engine.managed_order_controller.get(parent.id).state == "done" and len(venue.sent) == 1 finally: await engine.stop() @@ -347,11 +347,11 @@ async def stop_before_record(order, **kwargs): venue = DurableFakeVenue(venue_path) engine, _ = await scripted_engine(journal, venue) try: - parent = engine.orders.live()[0] + parent = engine.managed_order_controller.live()[0] assert len(parent.children) == 1 and parent.pending_child is None assert len(venue.orders) == 1 for _ in range(2): - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert len(venue.orders) == 1 assert len(venue.sent) == (1 if boundary == 'before_send' else 0) finally: @@ -369,7 +369,7 @@ async def test_legacy_checkpoint_client_key_is_reserved_during_migration(tmp_pat restarted, _ = await scripted_engine(path, venue) try: assert (await restarted.submit(stop_request())).id == original.id - assert len(restarted.orders.parents) == 1 and venue.sent == [] + assert len(restarted.managed_order_controller.parents) == 1 and venue.sent == [] finally: await restarted.stop() @@ -377,7 +377,7 @@ async def test_legacy_checkpoint_client_key_is_reserved_during_migration(tmp_pat async def test_cancel_a_planned_child_never_dispatches_it(tmp_path): engine, venue = await scripted_engine(tmp_path / "engine.db") parent = await engine.submit(stop_request()) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) managed.state = "working" child = managed.child_request(quantity=D("10"), price=D("0.01"), type="market", time_in_force="ioc") child = child.model_copy(update={"client_order_id": "planned-child"}) @@ -399,7 +399,7 @@ async def test_expired_planned_stop_limit_does_not_send_after_restart(tmp_path): path = tmp_path / "engine.db" engine, venue = await scripted_engine(path) parent = await engine.submit(stop_request(type="stop_limit", price="0.38", time_in_force="gtd", expires_at=int((engine.clock() + 60) * 1000))) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) managed.state = "working" managed.pending_child = managed.child_request(quantity=D("10"), price=D("0.38"), type="limit", time_in_force="gtd", expires_at=managed.request.expires_at).model_copy(update={"client_order_id": "planned-expiry"}) await engine.journal.plan_managed_child(managed.snapshot(), managed.pending_child, venue=managed.venue, account=managed.account) @@ -408,7 +408,7 @@ async def test_expired_planned_stop_limit_does_not_send_after_restart(tmp_path): restarted = Engine({"kalshi": venue}, config, clock=lambda: managed.request.expires_at / 1000 + 1) try: await restarted.start() - assert restarted.orders.get(parent.id).state == "expired" + assert restarted.managed_order_controller.get(parent.id).state == "expired" assert venue.sent == [] finally: await restarted.stop() @@ -430,7 +430,7 @@ async def unavailable(**kwargs): venue.fetch_open_orders = original restarted, _ = await scripted_engine(path, venue) try: - assert restarted.orders.get(parent.id).state == "canceled" + assert restarted.managed_order_controller.get(parent.id).state == "canceled" assert await venue.fetch_open_orders() == [] and len(venue.sent) == 1 finally: await restarted.stop() @@ -457,10 +457,10 @@ async def test_crossed_book_and_replayed_trade_do_not_trigger(setup): parent = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40")) engine.set_book("kalshi:KX-A", SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.30"))) await engine.on_book({"market_id": "kalshi:KX-A"}) - assert engine.orders.get(parent.id).state == "waiting" + assert engine.managed_order_controller.get(parent.id).state == "waiting" last = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40", trigger_source="last")) await engine.on_trade({"market_id": "kalshi:KX-A", "price": "0.39", "quantity": "1", "timestamp": engine.started_ts - 1000}) - assert engine.orders.get(last.id).state == "waiting" and paper.fills == [] + assert engine.managed_order_controller.get(last.id).state == "waiting" and paper.fills == [] async def test_risk_refusal_is_durable_on_retry(tmp_path): @@ -489,7 +489,7 @@ async def test_submission_reservation_and_audit_rollback_before_any_dispatch(tmp with pytest.raises(sqlite3.IntegrityError, match="simulated audit failure"): await engine.submit(stop_request()) assert await engine.journal.submission('protect-a') is None - assert engine.orders.parents == {} and venue.sent == [] + assert engine.managed_order_controller.parents == {} and venue.sent == [] finally: await engine.stop() @@ -520,7 +520,7 @@ async def slow(request): await asyncio.wait_for(cross(engine), 1) await asyncio.wait_for(entered.wait(), 1) await asyncio.wait_for(engine.on_book({'market_id': 'kalshi:KX-B'}), 1) - managed = engine.orders.get(second.id) + managed = engine.managed_order_controller.get(second.id) assert managed.state == 'working' and managed.pending_child is not None if action == 'cancel': await engine.cancel(second.id) @@ -529,7 +529,7 @@ async def slow(request): release.set() for _ in range(100): expected = 'canceled' if action == 'cancel' else 'expired' if action == 'expire' else 'working' - if engine.orders.get(first.id).children and managed.pending_child is None and managed.state == expected: + if engine.managed_order_controller.get(first.id).children and managed.pending_child is None and managed.state == expected: break await asyncio.sleep(0.005) assert managed.pending_child is None @@ -549,8 +549,8 @@ async def test_sell_stop_market_notional_limit_counts_the_no_price(tmp_path): await engine.set_risk(RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None, max_order_notional=D('5'))) await cross(engine) - assert engine.orders.get(parent.id).state == 'rejected' - assert '9.90' in engine.orders.get(parent.id).detail + assert engine.managed_order_controller.get(parent.id).state == 'rejected' + assert '9.90' in engine.managed_order_controller.get(parent.id).detail assert venue.sent == [], 'buying NO at a 0.01 YES limit can cost 0.99 per contract' finally: await engine.stop() @@ -567,7 +567,7 @@ async def accepted_then_decoder_failed(request): venue.create_order = accepted_then_decoder_failed try: await cross(engine) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "working" and managed.pending_child is not None assert "unresolved" in managed.detail and len(venue.sent) == 1 finally: @@ -575,7 +575,7 @@ async def accepted_then_decoder_failed(request): venue.create_order = original engine, _ = await scripted_engine(path, venue) try: - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert len(managed.children) == 1 and managed.pending_child is None assert len(venue.sent) == 1 finally: @@ -619,8 +619,8 @@ def __getattr__(self, name): await engine.stop() restarted, _ = await scripted_engine(path, venue) try: - await restarted.orders.on_timer() - assert restarted.orders.get(parent.id).state != "rejected" + await restarted.managed_order_controller.on_timer() + assert restarted.managed_order_controller.get(parent.id).state != "rejected" assert len(venue.sent) == 1 finally: await restarted.stop() From 498347559b9b414ec8df769176e4dd3165428d07 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 22 Oct 2026 15:01:53 +0100 Subject: [PATCH 2/4] fix: complete routed cancellation after delayed child updates --- .../engine/orders/lifecycle/managed-states.md | 4 +- .../orders/lifecycle/routed-lifecycle-plan.md | 38 ++-- synpath/engine/orders/lifecycle/routed.md | 27 ++- synpath/engine/orders/routed.py | 132 ++++++++++---- tests/test_routed_limit.py | 166 ++++++++++++++++++ 5 files changed, 299 insertions(+), 68 deletions(-) diff --git a/synpath/engine/orders/lifecycle/managed-states.md b/synpath/engine/orders/lifecycle/managed-states.md index 1e4d7c6..90b1171 100644 --- a/synpath/engine/orders/lifecycle/managed-states.md +++ b/synpath/engine/orders/lifecycle/managed-states.md @@ -1,6 +1,6 @@ # Managed order states in the current code -Status: current implementation walkthrough, read on 22 October 2026 at SDK commit `5ae2269` with local readability refactors. Proposed changes belong in [the routed lifecycle plan](routed-lifecycle-plan.md). +Status: current implementation walkthrough, updated on 22 October 2026 for the readability checkpoint and routed cancellation fix. Proposed changes belong in [the routed lifecycle plan](routed-lifecycle-plan.md). A managed order is an instruction the Engine continues running, such as a stop, iceberg or routed bucket order. Its state describes its execution phase. Its filled quantity is a separate measurement. This distinction lets an order be both canceled and partially filled. @@ -43,7 +43,7 @@ For an instruction to buy 100: 2. The controller forwards book updates, child order updates and timer ticks to that implementation. 3. Type-specific methods decide whether to continue, stop or finish. `finish()` sets state and detail, saves, then publishes an event. 4. The shared `cancel()` sets `cancelling` before pulling children. It stays pending while children remain live; otherwise it ends `done` if filled or uses the requested final state. -5. Shared `expire()` calls `cancel(..., end="expired")`. The controller checks explicit `expires_at` on timer ticks. +5. Shared `expire()` calls `cancel(..., end="expired")`. The controller checks explicit `expires_at` on timer ticks. Routed orders override `cancel()` to retain the intended ending, process delayed child results and retry cancellation without routing; see the routed lifecycle. 6. `_run_hook()` handles errors and saves the resulting parent. Network errors and timeouts retain the current state with a detail message. Other errors can end `rejected`, subject to the implementation's recorded uncertain-child information. A call to `finish()` does not itself verify that every child is terminal. Each caller is responsible for satisfying that condition. Read type-specific paths before assuming the shared helpers enforce it. diff --git a/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md b/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md index 67de743..0916a24 100644 --- a/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md +++ b/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md @@ -1,6 +1,6 @@ # Routed order lifecycle improvement plan -Status: documentation and planning only. Keep the existing statuses. No runtime behavior, schema or API changes are authorized by this plan. Read [shared managed states](managed-states.md) and [current routed behavior](routed.md) first. +Status: existing statuses retained; the user-authorized routed cancellation fix is implemented with offline regression tests. Stop-reason consistency and broader saving/recovery changes remain future work. No schema or public status changes were made. Read [shared managed states](managed-states.md) and [current routed behavior](routed.md) first. ## Agreed direction @@ -27,27 +27,27 @@ The issue is consistent population across paths, not a need to combine reason an | Parent is constructed | `waiting` | `waiting` | `ManagedOrder.__init__()` | | Routing starts | `working` | `triggered` | `RoutedLimit.start()` | | Book changes or part of the quantity fills | Usually still `working` | `triggered` | `on_book()`, `on_fill()`, `on_child_order_updated()`, `_replan()` | -| User cancellation begins | `cancelling` | `pending_cancel` | Inherited `ManagedOrder.cancel()` | +| User cancellation begins | `cancelling` | `pending_cancel` | Routed `cancel()` | | Every child is resolved, requested quantity filled | `done` | `closed` | Completion paths | | Every child is resolved, some requested quantity remains | `canceled`, or `expired` for explicit expiry | Same as internal state | Cancellation or expiry path | -Routing-boundary `stop()` is a separate path: it sets `stop_reason`, pulls children, and only sets `cancelling` if children are still live afterwards. Thus it can call venues while the parent is still `working`. `max_age` currently ends `canceled` when incomplete, while explicit expiry requests `expired`. Whether to align these endings remains open; neither is changed by this plan. +Routing-boundary `stop()` sets `stop_reason` and now uses the same routed `cancel()` lifecycle: enter `cancelling` before venue calls and continue cleanup until tracked children end. `max_age` currently ends `canceled` when incomplete, while explicit expiry requests `expired`. Whether to align these endings remains open; neither is changed by this plan. ## Walkthrough of delayed cancellation Example: buy 100; 30 filled and 70 remain resting at one venue. -1. User cancels. Inherited `cancel()` sets `cancelling` and saves before requesting child cancellation. -2. The venue acknowledges the request but still reports the child open. `cancel_live_children_without_waiting()` keeps that child live, so the parent remains `cancelling` with 30 filled. -3. Later, the venue reports the child canceled. The controller updates the child's record through `update_child_from_venue_order()` before invoking parent callbacks. -4. Routed `on_child_order_updated()` calls `_ready()`, which returns false unless the parent is `working`. It returns without finishing the `cancelling` parent. -5. An ordinary routed timer tick has the same gate and also returns. Unlike repeated explicit expiry, it does not retry the inherited cancellation path. +1. User cancels. Routed `cancel()` sets `cancelling`, records the intended final state and saves before requesting child cancellation. +2. The venue acknowledges but reports the child open. The child remains live, so the parent remains `cancelling` with 30 filled. +3. Later, the venue reports the child canceled. The controller updates the child through `update_child_from_venue_order()` before invoking parent callbacks. +4. `on_child_order_updated()` handles `cancelling` separately: remove inactive legs, check remaining live children, and finish with the retained ending if none remain. Full quantity filled wins and ends `done`. +5. If no final update arrives, timer ticks retry child cancellation without placing replacements. -This explains a potential stuck transition: child records can be correct while the parent remains pending. The code path is observed; a dedicated delayed-acknowledgment test should reproduce it before a fix is designed. +Before the fix, `_ready()` allowed only `working`, so both child and ordinary timer callbacks returned without completing the pending parent. The regression tests reproduce those failures on the committed readability checkpoint and pass with the fix. ## Transition rules to review -These rules are proposed behavior, not a claim that every current path enforces them. +Tracked-child cancellation rules below are implemented and tested. Uncertain submissions outside the tracked-child list and failure-ending policy remain separate review items. | Transition or observation | Intended rule | |---|---| @@ -75,18 +75,18 @@ stateDiagram-v2 cancelling --> expired: incomplete and expiry cleanup complete ``` -This focuses on normal execution and stopping. Safe rejection and pre-parent validation failure need their own reviewed cases. It does not assert that `stop()` currently enters `cancelling` before venue calls. +This focuses on normal execution and stopping. Safe rejection and pre-parent validation failure need their own reviewed cases. `stop()` now enters `cancelling` before requesting venue cancellation. -## Offline cases before implementation +## Offline verification checklist - [ ] Ordinary full fill and partial fill retain correct quantities and existing public statuses. - [ ] Immediate cancellation completes with the expected quantity and reason. -- [ ] Cancellation acknowledgment still showing open stays pending. -- [ ] A later child cancellation completes that pending parent. -- [ ] A cancellation failure is retried without allocating new quantity. -- [ ] A final fill during cancellation yields full completion; a partial fill is retained while cancellation continues. -- [ ] Book updates and reentrant child callbacks during stopping cannot submit replacements. -- [ ] Routing-boundary stop and explicit expiry obey the same cleanup rules, with their chosen final-state policy. +- [x] Cancellation acknowledgment still showing open stays pending. +- [x] A later child cancellation completes that pending parent. +- [x] A cancellation failure is retried without allocating new quantity. +- [x] A final fill during cancellation yields full completion; a partial fill is retained while cancellation continues. +- [x] Book updates and reentrant child callbacks during stopping cannot submit replacements. +- [x] Routing-boundary stop and explicit expiry obey the same cleanup rules, with their chosen final-state policy. - [ ] No-books and error paths do not imply confirmed cancellation or safe rejection. Use existing paper/scripted venues. Do not place credentialed or real-money orders for this work. @@ -99,4 +99,4 @@ That later review covers pending child identity, response loss, unavailable venu ## Next code reading session -Read inherited `cancel()` in `base.py`, then `cancel_live_children_without_waiting()`. Follow the delayed venue result through `ManagedOrderController.on_order()` into `RoutedLimit.on_child_order_updated()` and `_ready()`. Compare that with routed `stop()` and `on_timer()`. These call sites show why entering a state is only half of a lifecycle: something must also advance it to its ending. +Read routed `cancel()` and `_finish_cancellation_if_children_stopped()`, then the shared `cancel_live_children_without_waiting()`. Follow a venue result through `ManagedOrderController.on_order()` into `RoutedLimit.on_child_order_updated()`. Compare its cancelling branch with its working branch, and read `_refresh_restored_children_if_needed()` separately. These call sites show why entering a state is only half of a lifecycle: something must also advance it to its ending. diff --git a/synpath/engine/orders/lifecycle/routed.md b/synpath/engine/orders/lifecycle/routed.md index 4038f02..54aad0c 100644 --- a/synpath/engine/orders/lifecycle/routed.md +++ b/synpath/engine/orders/lifecycle/routed.md @@ -16,11 +16,11 @@ Status: current behavior, not the proposed contract. Read alongside [shared mana | `start()` | Sets `working`, records start time, and plans immediately. Public status is currently `triggered`. | | Books change or child progress arrives | While ready and `working`, replan remaining quantity and keep, cancel or add children. A partial fill normally leaves the parent `working`. | | No member books available | Remain `working`, publish `reason=no_books`, save and wait. | -| Full fill | Completion paths use `done` / public `closed`. `complete_if_done()` also requires no live children. Some other routed paths pull children then call `finish()` directly. | +| Full fill | Completion paths use `done` / public `closed`. `complete_if_done()` also requires no live children. Routed full-fill paths now use cancellation cleanup if other tracked children remain live. | | Plan has an unfilled remainder and a reason, with no live children | `stop(reason)` ends `canceled` if incomplete. Reasons include `worst_price`, `liquidity`, `min_quantity`. | -| `max_rounds` reached | `stop("max_rounds")` pulls children, then ends `canceled` if incomplete or waits in `cancelling` if children remain live. | +| `max_rounds` reached | `stop("max_rounds")` enters `cancelling` before venue calls, then ends `canceled` if incomplete once tracked children have ended. | | `max_age_s` reached on timer | Same stop path with `max_age`; currently an incomplete order ends `canceled`, not `expired`. | -| User cancel or halt | Inherited `cancel()` enters `cancelling`, pulls children, then ends `done` if filled or `canceled` if incomplete and no live children remain. | +| User cancel or halt | Routed `cancel()` enters `cancelling` before venue calls, retains the intended final state, and finishes after all tracked children have ended. Timer ticks retry pending cancellations. | | Explicit `expires_at` reached | Shared timer calls inherited `expire()`, requesting final state `expired` after children are stopped. | | Error during a controller callback | Shared `_run_hook()` handles it. A parent can become `rejected`; uncertainty handling is not equivalent for all order types. | @@ -34,19 +34,28 @@ Explicit expiry plumbing exists, so “bucket orders can never expire” is inac For 100 requested, 30 filled and no liquidity remaining, the routed report can say `state=canceled`, `filled=30`, `unfilled=70`, `stop_reason=liquidity`. A client can display “30/100 filled · stopped: liquidity”. This does not require a separate partial-fill state. -The inherited cancel/expiry path does not populate routed `stop_reason` in the same way as `stop()`. General error paths also use detail text. The reason is therefore not consistent across every ending. +The user-cancel/expiry path does not populate routed `stop_reason` in the same way as `stop()`. General error paths also use detail text. The reason is therefore not consistent across every ending. ## Lifecycle behavior to review - The shared mapping names active routing `triggered`. We are keeping that mapping; document it as active managed execution rather than treating it as a defect. - `max_age` and explicit expiry use different final states for time-based endings. -- `stop()` attempts child cancellation before setting `cancelling`, unlike the shared cancel path. Child updates can call back into the parent while it is still working. -- After `cancelling`, routed `_ready()` returns false. Routed `on_timer()` and `on_child_order_updated()` then skip their ordinary work. General pending-cancellation retry and completion are not clearly covered by that path; repeated explicit expiry has separate controller handling. -- Some full-fill/error paths can call `finish()` without a shared guarantee that all children are resolved. Tests must establish when a final state is safe. +- Stopping now marks `cancelling` before any cancellation calls. Reentrant callbacks process child progress without resuming routing. +- `on_child_order_updated()` and `on_fill()` can finish a cancelling parent after its last tracked child ends. `on_timer()` retries cancellation when a response still reports open or a request fails. +- The intended incomplete ending (`pending_final_state`) is included in the existing managed snapshot so delayed explicit expiry remains `expired`. The first stopping cause/end is retained across repeated cancellation attempts. +- Shared error paths still need a separate review before claiming all unresolved venue submissions prevent finalization. This fix handles tracked children, not every unknown-send case. - End reasons differ between routing stop, user cancellation, expiry and errors. -These are code-path observations, not a claim that every scenario has been reproduced. The draft plan identifies the offline cases needed before changing behavior. +Offline regression tests in `tests/test_routed_limit.py: TestPendingCancellation` reproduce delayed cancellation, fill races, retries and reentrant callbacks. Public status names, routing reasons and `max_age` semantics are unchanged. + +## What resync means + +`resync()` attempts to refresh child order records after the Engine restarts. It fetches each known child from its venue, replaces saved status/progress, and adopts additional children linked to the parent in the journal. A working parent can then consider its remainder; a cancelling parent only continues cleanup. + +`_resynced` means this refresh has been attempted in the current process. New parents set it true in `start()` because they have no restored children. Restored parents begin false. The method sets it true before venue reads and currently logs/skips failed reads or missing adapters. Therefore true does **not** mean all children were successfully refreshed. + +`_refresh_restored_children_if_needed()` performs that attempt independently of lifecycle state. Routing callbacks then check `state == "working"`; cancelling callbacks instead advance cleanup. There is no longer a working-only `_ready()` gate that drops stopping updates. ## Next phase boundary -The Engine owns the instruction; venues own child acceptance and fills. Current routing progress is saved through the managed snapshot path and child records. This phase changes neither storage nor restart behavior. The following phase must assess saved child identities, failed venue reads, uncertain sends and cancellation races before live guarantees can be claimed. +The Engine owns the instruction; venues own child acceptance and fills. Routing progress and the intended pending ending use the existing managed snapshot path; no schema or table layout changed. The following phase must assess saved child identities, failed venue reads, uncertain sends and cancellation races across process failure before stronger recovery guarantees can be claimed. diff --git a/synpath/engine/orders/routed.py b/synpath/engine/orders/routed.py index fae40be..f1e0ca7 100644 --- a/synpath/engine/orders/routed.py +++ b/synpath/engine/orders/routed.py @@ -33,7 +33,7 @@ from ...bucket import Bucket, bucket_id_of from ...trading.types import Fill, Order, OrderRequest, OrderType, Precision, Side, TimeInForce from .. import router -from .base import ZERO, Child, Context, D, ManagedOrder, now_ms +from .base import ZERO, Child, Context, D, ManagedOrder, State, now_ms from .manager import register FEE_REFERENCE_TRIES = 4 @@ -105,6 +105,8 @@ def __init__(self, *args: Any, **kwargs: Any): self.rounds = 0 self.started_at = 0.0 self.stop_reason = "" + self.pending_final_state: State = "canceled" + """The incomplete order's intended ending while child cancellations are pending.""" self.fees_paid = ZERO self.fee_cache: dict[str, Decimal] = {} self.fee_floors: dict[str, Decimal] = {} @@ -114,8 +116,11 @@ def __init__(self, *args: Any, **kwargs: Any): self._replanning = False self._replan_again = False self._resynced = False - """False until this parent has asked the venues about its legs after - a restart. A fresh parent sets it in `start`.""" + """Whether refresh of restored children has been attempted in this process. + New parents set this in start() because they have no restored children. + resync() sets it before reading venues; failed reads are logged and + skipped, so True does not prove every child's status is current. + """ # -- identity: the bucket, and every member --------------------------------- @@ -162,12 +167,17 @@ async def start(self, ctx: Context) -> None: await self.replan(ctx, first=True) async def resync(self, ctx: Context) -> None: - """After a restart: the venues are the authority on what each leg - did while this process was away. Ask every venue for every child, - take in any child the journal links to this parent that the - snapshot did not carry (an intent that was in doubt when the - process died), then plan the remainder. Nothing is placed before - this has run.""" + """Refresh saved child records after a restart before acting on them. + + Read each known child from its venue, replace locally saved status and + progress, and adopt additional children linked in the journal. A working + parent then considers its remainder; a cancelling parent only resumes + cleanup through its caller, never routing. + + This is an attempted refresh, not an all-venues-success barrier: + _resynced is set before reads, and unavailable or failing venues are + currently skipped. Stronger failed-read recovery is separate work. + """ self._resynced = True for child in list(self.children): adapter = ctx.engine.adapters.get(child.venue) @@ -199,51 +209,57 @@ async def resync(self, ctx: Context) -> None: await ctx.publish("managed.resynced", {"children": len(self.children), "filled": str(self.known_filled)}) if self.state == "working": if self.remaining <= 0: - await self.cancel_live_children_without_waiting(ctx) - await self.finish(ctx, "done", "filled") + await self.cancel(ctx, "filled") else: await self.replan(ctx) - async def _ready(self, ctx: Context) -> bool: - """Refresh restored children if needed, then allow only active routing.""" + async def _refresh_restored_children_if_needed(self, ctx: Context) -> None: + """Attempt restart refresh independently of whether this parent may route.""" if not self._resynced: await self.resync(ctx) - return self.state == "working" async def on_book(self, ctx: Context, market_id: str) -> None: - if await self._ready(ctx): + await self._refresh_restored_children_if_needed(ctx) + if self.state == "working": await self.replan(ctx) async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: self.fees_paid += fill.fee or ZERO - if not await self._ready(ctx): + await self._refresh_restored_children_if_needed(ctx) + if self.state == "cancelling": + await self._finish_cancellation_if_children_stopped(ctx) + return + if self.state != "working": return if self.remaining <= 0: - await self.cancel_live_children_without_waiting(ctx) - await self.finish(ctx, "done", "filled") + await self.cancel(ctx, "filled") return await self.replan(ctx) async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: - """React to an ended child: remove its leg and consider the remaining quantity.""" - # The controller has already updated the child's status and fill totals. - # Current limitation: _ready() rejects a cancelling parent, so even a - # confirmed child cancellation returns here without finishing that parent. - # Pending-cancellation completion is tracked in the lifecycle plan. - if not await self._ready(ctx) or not order.is_terminal: + """Advance stopping children, or replan an active order after a child ends.""" + # The controller already recorded the child's venue status and progress. + # A stopping parent still needs those results to finish; it must not route. + await self._refresh_restored_children_if_needed(ctx) + if self.state == "cancelling": + await self._finish_cancellation_if_children_stopped(ctx) return - for market_id, leg in list(self.legs.items()): - if leg.order_id == order.id and leg.venue == order.venue: - self.legs.pop(market_id, None) + if self.state != "working" or not order.is_terminal: + return + self._forget_dead_legs() if self.remaining <= 0: await self.complete_if_done(ctx) else: await self.replan(ctx) async def on_timer(self, ctx: Context) -> None: - # This has the same working-only gate: ordinary timer ticks currently - # do not retry cancellation or finish a parent already cancelling. - if not await self._ready(ctx): + await self._refresh_restored_children_if_needed(ctx) + if self.state == "cancelling": + # An open cancellation response or a failed request needs another + # attempt, even if no more book or order notifications arrive. + await self.cancel(ctx, self.detail, end=self.pending_final_state) + return + if self.state != "working": return if self.max_age_s and ctx.now - self.started_at >= self.max_age_s: await self.stop(ctx, "max_age") @@ -251,17 +267,40 @@ async def on_timer(self, ctx: Context) -> None: if not self.live_children and self.remaining > 0: await self.replan(ctx) - async def stop(self, ctx: Context, reason: str) -> None: - """Stand down with what filled. `done` only when it all did.""" - self.stop_reason = reason + async def cancel(self, ctx: Context, reason: str = "cancelled", *, end: State = "canceled") -> None: + """Stop allocating first, then request child cancellation and await later results.""" + if not self.live: + return + if self.state != "cancelling": + # Mark stopping before any await that can deliver a child callback. + # Later retries preserve the original ending (including explicit expiry). + self.pending_final_state = end + self.detail = reason + self.state = "cancelling" + await ctx.save() + await self._refresh_restored_children_if_needed(ctx) await self.cancel_live_children_without_waiting(ctx) + await self._finish_cancellation_if_children_stopped(ctx) + + async def _finish_cancellation_if_children_stopped(self, ctx: Context) -> None: + """Complete only after all tracked children ended; a full fill wins over cancellation.""" + if self.state != "cancelling": + return + self._forget_dead_legs() + if self.live_children: + return if self.remaining <= 0: await self.finish(ctx, "done", "filled") - elif self.live_children: - self.state = "cancelling" - await ctx.save() else: - await self.finish(ctx, "canceled", f"stopped: {reason}") + await self.finish(ctx, self.pending_final_state, self.detail) + + async def stop(self, ctx: Context, reason: str) -> None: + """Apply a routing boundary using the same pending-cancellation lifecycle.""" + if not self.live: + return + if self.state != "cancelling": + self.stop_reason = reason + await self.cancel(ctx, f"stopped: {reason}") # -- the plan and the legs ------------------------------------------------ @@ -272,6 +311,8 @@ async def replan(self, ctx: Context, *, first: bool = False) -> None: """Re-entrant safe: a cancel inside a plan can hand the engine a terminal child, whose `on_child_order_updated` asks for a plan of its own. That request is noted and honoured once this plan is through.""" + if self.state != "working": + return if self._replanning: self._replan_again = True return @@ -287,8 +328,10 @@ async def replan(self, ctx: Context, *, first: bool = False) -> None: async def _replan(self, ctx: Context, *, first: bool = False) -> None: """Reconcile the new allocation with existing children before adding orders.""" + if self.state != "working": + return if self.known_remaining <= 0: - await self.complete_if_done(ctx) + await self.cancel(ctx, "filled") return canceled_count = await self._clean_up_existing_legs(ctx) @@ -300,6 +343,8 @@ async def _replan(self, ctx: Context, *, first: bool = False) -> None: plan = await self._calculate_routing_plan(ctx, member_books) proposed_legs = {leg.market_id: leg for leg in plan.legs} canceled_count += await self._keep_or_cancel_existing_legs(ctx, member_books, proposed_legs) + if self.state != "working": + return placed_count = await self._place_new_legs(ctx, proposed_legs) await self._record_routing_result( ctx, plan, placed_count=placed_count, canceled_count=canceled_count, first=first, @@ -399,6 +444,9 @@ async def _place_new_legs(self, ctx: Context, proposed_legs: dict[str, router.Le available_quantity = self.budget() placed_count = 0 for market_id, proposed_leg in proposed_legs.items(): + # A venue response can call back into the parent and start stopping. + if self.state != "working": + break quantity = min(proposed_leg.quantity, available_quantity) precision = self.precision[market_id] if quantity <= 0 or quantity < precision.min_quantity: @@ -428,6 +476,8 @@ async def _record_routing_result( self, ctx: Context, plan: router.Plan, *, placed_count: int, canceled_count: int, first: bool, ) -> None: """Report the round, stop at the applicable bounds, otherwise save working state.""" + if self.state != "working": + return # Count only later rounds that changed an order. Replans caused by a # cancellation's own notification do not count unless they change something. if not first and (placed_count or canceled_count): @@ -573,6 +623,7 @@ def extra(self) -> dict[str, Any]: "precision": {k: v.model_dump(mode="json") for k, v in self.precision.items()}, "legs": {k: v.to_dict() for k, v in self.legs.items()}, "rounds": self.rounds, "started_at": self.started_at, "stop_reason": self.stop_reason, + "pending_final_state": self.pending_final_state, "fees_paid": str(self.fees_paid), "fee_cache": {k: str(v) for k, v in self.fee_cache.items()}, "fee_floors": {k: str(v) for k, v in self.fee_floors.items()}, } @@ -590,6 +641,11 @@ def load_extra(self, extra: dict[str, Any]) -> None: self.rounds = int(extra.get("rounds") or 0) self.started_at = float(extra.get("started_at") or 0) self.stop_reason = extra.get("stop_reason") or "" + # Older explicit-expiry snapshots used the fixed detail "expired" but + # did not store an intended ending separately. Preserve that ending. + self.pending_final_state = extra.get("pending_final_state") or ( + "expired" if self.detail == "expired" else "canceled" + ) self.fees_paid = D(extra.get("fees_paid"), ZERO) self.fee_cache = {k: D(v) for k, v in (extra.get("fee_cache") or {}).items()} self.fee_floors = {k: D(v) for k, v in (extra.get("fee_floors") or {}).items()} diff --git a/tests/test_routed_limit.py b/tests/test_routed_limit.py index 75a81c8..5d8b47e 100644 --- a/tests/test_routed_limit.py +++ b/tests/test_routed_limit.py @@ -342,3 +342,169 @@ async def test_a_floor_worth_paying_is_paid(self, setup): book(poly, engine, P, asks=[(D("0.40"), D("1000"))]) await engine.submit(request(bucket, quantity=D("50"), price=D("0.46"))) assert kalshi.orders == {} and [o.quantity for o in poly.orders.values()] == [D("50")] + + +class TestPendingCancellation: + """A cancellation response may precede the final child status.""" + + async def resting_parent(self, setup, **request_fields): + engine, kalshi, poly, clock, bucket = setup + book(kalshi, engine, K, asks=[(D("0.41"), D("30"))]) + book(poly, engine, P, asks=[(D("0.42"), D("100"))]) + # The engine saw liquidity, but it moved before the children reached the venues. + kalshi.set_book(K, asks=[(D("0.50"), D("100"))]) + poly.set_book(P, asks=[(D("0.50"), D("100"))]) + order = await engine.submit(request(bucket, **request_fields)) + return engine.managed_order_controller.get(order.id) + + def acknowledge_without_canceling(self, monkeypatch, *venues): + original_cancellations = {} + for venue in venues: + original_cancellations[venue.venue] = venue.cancel_order + + async def acknowledge(order_id, *, market_id=None, current=None, venue=venue): + return await venue.fetch_order(order_id) + + monkeypatch.setattr(venue, "cancel_order", acknowledge) + return original_cancellations + + async def confirm_cancellations(self, engine, venues, original_cancellations): + for venue in venues: + for child_order in list(open_orders(venue)): + result = await original_cancellations[venue.venue](child_order.id) + await engine.on_order(result) + + async def test_delayed_child_confirmation_finishes_a_cancelling_parent(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + original_cancellations = self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + original_child_ids = {(child.venue, child.order_id) for child in parent.children} + + await engine.managed_order_controller.cancel(parent.id) + assert parent.state == "cancelling" and len(parent.live_children) == 2 + await engine.on_book({"market_id": K}) + assert {(child.venue, child.order_id) for child in parent.children} == original_child_ids + + await self.confirm_cancellations(engine, (kalshi, poly), original_cancellations) + assert parent.state == "canceled" + assert parent.filled == 0 and parent.remaining == 50 + assert not parent.live_children and not parent.legs + assert len(kalshi.orders) + len(poly.orders) == 2 + + @pytest.mark.parametrize("fill_all", [False, True]) + async def test_fills_during_cancellation_are_retained_and_full_fill_wins(self, setup, monkeypatch, fill_all): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + original_cancellations = self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + await engine.managed_order_controller.cancel(parent.id) + + kalshi.on_trade(K, price=D("0.41"), quantity=D("10"), taker_side=Side.SELL) + await deliver(kalshi, poly) + assert parent.filled == 10 and parent.state == "cancelling" + if fill_all: + kalshi.on_trade(K, price=D("0.41"), quantity=D("20"), taker_side=Side.SELL) + poly.on_trade(P, price=D("0.42"), quantity=D("20"), taker_side=Side.SELL) + await deliver(kalshi, poly) + else: + await self.confirm_cancellations(engine, (kalshi, poly), original_cancellations) + + assert parent.state == ("done" if fill_all else "canceled") + assert parent.filled == (50 if fill_all else 10) + assert not parent.live_children + assert len(kalshi.orders) + len(poly.orders) == 2 + + @pytest.mark.parametrize("first_attempt_fails", [False, True]) + async def test_timer_retries_pending_cancellations_without_new_allocations( + self, setup, monkeypatch, first_attempt_fails, + ): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + attempts = {venue.venue: 0 for venue in (kalshi, poly)} + for venue in (kalshi, poly): + original_cancel = venue.cancel_order + + async def cancel_after_retry( + order_id, *, market_id=None, current=None, venue=venue, original_cancel=original_cancel, + ): + attempts[venue.venue] += 1 + if attempts[venue.venue] == 1: + if first_attempt_fails: + raise TimeoutError("cancellation response was lost") + return await venue.fetch_order(order_id) + return await original_cancel(order_id, market_id=market_id) + + monkeypatch.setattr(venue, "cancel_order", cancel_after_retry) + + await engine.managed_order_controller.cancel(parent.id) + assert parent.state == "cancelling" + await engine.managed_order_controller.on_timer() + assert parent.state == "canceled" and not parent.live_children + assert attempts == {"kalshi": 2, "polymarket": 2} + assert len(kalshi.orders) + len(poly.orders) == 2 + + async def test_routing_stop_enters_cancelling_before_venue_callbacks(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + states_at_cancel = [] + replan_calls = [] + + async def record_replan(ctx, **kwargs): + replan_calls.append(parent.state) + + monkeypatch.setattr(parent, "replan", record_replan) + for venue in (kalshi, poly): + original_cancel = venue.cancel_order + + async def observe_cancel(order_id, *, market_id=None, current=None, original_cancel=original_cancel): + states_at_cancel.append(parent.state) + # Simulate a book update arriving inside a cancellation call. + await engine.on_book({"market_id": K}) + return await original_cancel(order_id, market_id=market_id) + + monkeypatch.setattr(venue, "cancel_order", observe_cancel) + + await engine.managed_order_controller.run(parent, lambda ctx: parent.stop(ctx, "max_rounds")) + assert states_at_cancel == ["cancelling", "cancelling"] + assert not replan_calls, "neither book nor cancellation callbacks may resume routing" + assert parent.state == "canceled" and parent.stop_reason == "max_rounds" + assert len(kalshi.orders) + len(poly.orders) == 2 + + async def test_delayed_expiry_keeps_its_intended_final_state(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + expiry = int((clock.t + 10) * 1000) + parent = await self.resting_parent(setup, time_in_force="gtd", expires_at=expiry) + original_cancellations = self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + clock.tick(11) + await engine.managed_order_controller.on_timer() + assert parent.state == "cancelling" + + await self.confirm_cancellations(engine, (kalshi, poly), original_cancellations) + assert parent.state == "expired" and not parent.live_children + assert parent.filled == 0 and parent.remaining == 50 + + + @pytest.mark.parametrize("end", ["canceled", "expired"]) + async def test_pending_final_state_survives_snapshot_restore(self, setup, monkeypatch, end): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + await engine.managed_order_controller.run( + parent, lambda ctx: parent.cancel(ctx, "test pending ending", end=end), + ) + snapshot = parent.snapshot() + restored = type(parent).from_snapshot(snapshot) + assert restored.state == "cancelling" + assert restored.pending_final_state == end + assert not restored._resynced, "the restored child records still need a venue refresh attempt" + + async def test_legacy_expiry_snapshot_preserves_expired_ending(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + await engine.managed_order_controller.run( + parent, lambda ctx: parent.cancel(ctx, "expired", end="expired"), + ) + snapshot = parent.snapshot() + snapshot["extra"].pop("pending_final_state") + restored = type(parent).from_snapshot(snapshot) + assert restored.state == "cancelling" and restored.pending_final_state == "expired" From e2df3ba315a45eccc2f190c9db04291e26f87eca Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 22 Oct 2026 15:11:12 +0100 Subject: [PATCH 3/4] test: verify pending routed cancellation across SQLite restart --- docs/rfq-combo.md | 2 +- synpath/engine/rfq.py | 2 +- tests/test_routed_recovery.py | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/rfq-combo.md b/docs/rfq-combo.md index c806f04..4c005b1 100644 --- a/docs/rfq-combo.md +++ b/docs/rfq-combo.md @@ -140,7 +140,7 @@ An RFQ is sized by contracts (`quantity`) **or** money (`amount`), never both. 1. **Accept is an order, and returns at once.** - Re-read the quote (it must be `open`), take the price for the requested outcome and action, and refuse if it is worse than `worst_price`. Size an `amount` here (§3). - - Build an `OrderRequest(type=rfq_taker, outcome, action, quantity, price, params={rfq_id, quote_id})` and run `engine.check`: the kill switch, a paused book, the restricted list, size and notional, position caps, closing soon, daily loss. The price collar is skipped, as for market orders, because combos rarely have a mark. + - Build an `OrderRequest(type=rfq_taker, outcome, action, quantity, price, params={rfq_id, quote_id})` and run `engine.evaluate_order_risk`: the kill switch, a paused book, the restricted list, size and notional, position caps, closing soon, daily loss. The price collar is skipped, as for market orders, because combos rarely have a mark. - Journal the intent (`operation="accept_quote"`, `target_order_id=quote_id`, key `rfq-accept:{venue}:{quote_id}`), mark it `sending`, call the venue, and return the intent. A network error raises `SubmissionInDoubt` and leaves the intent `sending`. - An optional `wait` (with `timeout_s`) blocks until the quote is terminal; the default does not wait. 2. **Results arrive as events.** A follower polls the quote (or reads the venue's stream): diff --git a/synpath/engine/rfq.py b/synpath/engine/rfq.py index bd8a04c..7afc006 100644 --- a/synpath/engine/rfq.py +++ b/synpath/engine/rfq.py @@ -41,7 +41,7 @@ **Offers commit nothing.** Creating an RFQ and sending a maker quote are journaled and checked against the halt, a paused book, the restricted list -and the order size limits (`RiskEngine.evaluate_order_risk_offer`), not the position +and the order size limits (`RiskEngine.check_offer`), not the position rules, which apply when the offer trades. A maker quote's `expires_at` is held by the engine, which cancels the quote when it passes. A halt with the `cancel` policy cancels every open RFQ we asked and every quote we made, on diff --git a/tests/test_routed_recovery.py b/tests/test_routed_recovery.py index f06f539..556dac2 100644 --- a/tests/test_routed_recovery.py +++ b/tests/test_routed_recovery.py @@ -174,3 +174,59 @@ async def test_a_finished_bucket_order_is_still_reported_after_a_restart(tmp_pat assert await again.bucket_order("nope") is None finally: await again.stop() + + +@pytest.mark.parametrize("ending", ["canceled", "expired"]) +async def test_pending_cancellation_resumes_after_sqlite_restart_without_new_orders(tmp_path, monkeypatch, ending): + engine, kalshi, poly, clock, path, order = await resting_legs(tmp_path) + parent = engine.managed_order_controller.get(order.id) + original_cancellations = {venue.venue: venue.cancel_order for venue in (kalshi, poly)} + for venue in (kalshi, poly): + async def acknowledge_only(order_id, *, market_id=None, current=None, venue=venue): + return await venue.fetch_order(order_id) + monkeypatch.setattr(venue, "cancel_order", acknowledge_only) + + await engine.managed_order_controller.run( + parent, lambda ctx: parent.cancel(ctx, "pending restart test", end=ending), + ) + assert parent.state == "cancelling" and len(parent.live_children) == 2 + await engine.journal.close() + + # The venue makes progress while this process cannot receive notifications. + # Restart must learn that progress before retrying the remaining cancellation. + kalshi.on_trade(K, price=D("0.41"), quantity=D("10"), taker_side=Side.SELL) + await original_cancellations["kalshi"](parent.children[0].order_id) + for venue in (kalshi, poly): + monkeypatch.setattr(venue, "cancel_order", original_cancellations[venue.venue]) + + refreshed = [] + for venue in (kalshi, poly): + original_fetch = venue.fetch_order + async def record_refresh(order_id, *, original_fetch=original_fetch, venue=venue): + refreshed.append((venue.venue, order_id)) + return await original_fetch(order_id) + monkeypatch.setattr(venue, "fetch_order", record_refresh) + + restarted = new_engine(path, kalshi, poly, clock) + await restarted.start() + try: + revived = restarted.managed_order_controller.get(order.id) + assert revived.state == "cancelling" and not revived._resynced + assert revived.pending_final_state == ending + # Supply attractive books: stopping parents must still refuse to allocate. + kalshi.set_book(K, asks=[(D("0.40"), D("100"))]) + poly.set_book(P, asks=[(D("0.40"), D("100"))]) + restarted.set_book(K, kalshi.books[K]) + restarted.set_book(P, poly.books[P]) + await restarted.managed_order_controller.on_timer() + + assert revived.state == ending and not revived.live_children + assert revived._resynced + assert set(refreshed) >= {(child.venue, child.order_id) for child in parent.children} + assert revived.filled == D("10") and revived.remaining == D("40") + assert len(kalshi.orders) == 1 and len(poly.orders) == 1 + assert open_orders(kalshi) == [] and open_orders(poly) == [] + stored = await restarted.journal.order(revived.venue, order.id) + assert stored.status == OrderStatus(ending) and stored.filled == D("10") + finally: + await restarted.stop() From 1d86b9f4a5d71ae3d16b758b33a770052f5bfee2 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 22 Oct 2026 15:12:23 +0100 Subject: [PATCH 4/4] docs: clarify experimental journal downgrade limits --- synpath/engine/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synpath/engine/README.md b/synpath/engine/README.md index 958cd08..c4076df 100644 --- a/synpath/engine/README.md +++ b/synpath/engine/README.md @@ -76,9 +76,9 @@ instructions silently. Unattributed venue observations with no original request are preserved in `historical_order_records`, with their original identity and snapshot still available through `typed_order`. -Back up and verify restore before upgrading an existing store. These revisions -refuse destructive downgrade; rollback requires compatible code and a verified -schema/data backup. Migrations are application-managed because they require +Back up and verify restore before upgrading an existing store. The owner migration refuses downgrade; later revision downgrades may remove +fields or recreate dropped tables empty. Rollback requires compatible code and +a verified schema/data backup. Migrations are application-managed because they require owner selection and locking; standalone/offline Alembic without that context is refused. Selecting PostgreSQL creates a new store. Importing an existing SQLite journal, including armed orders, is a separate operation and is not automatic.