feat(execution): reconcile timed-out orders safely

This commit is contained in:
Hermes Agent
2026-08-14 15:15:52 +09:00
parent 482b00e54d
commit 147873cb5d
21 changed files with 801 additions and 87 deletions
+44 -2
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import threading
from contextlib import contextmanager
from dataclasses import replace
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Iterator, Optional
from uuid import UUID, uuid4
@@ -163,6 +163,7 @@ class InMemorySlotRepository:
# --- order execution queue (BAT-004 / REQ-013, option A) ---
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
_KST = timezone(timedelta(hours=9))
def list_undispatched_pending(
self,
@@ -191,15 +192,20 @@ class InMemorySlotRepository:
broker_order_id: str,
*,
venue: Optional[str] = None,
submitted_at: Optional[datetime] = None,
) -> bool:
"""Atomically persist dispatch identity and the actual broker venue."""
"""Atomically persist dispatch identity and its immutable clock."""
s = self._slots.get(slot_id)
if s is None or s.broker_order_id is not None:
return False
submitted_at = submitted_at or datetime.now(tz=timezone.utc)
self._slots[slot_id] = replace(
s,
broker_order_id=broker_order_id,
venue=venue if venue is not None else s.venue,
order_submitted_at=submitted_at,
order_date=submitted_at.astimezone(self._KST).strftime("%Y%m%d"),
cancel_attempts=0,
updated_at=datetime.now(tz=timezone.utc),
)
return True
@@ -245,6 +251,39 @@ class InMemorySlotRepository:
)
return True
def reserve_cancel_attempt(
self,
slot_id: UUID,
broker_order_id: str,
*,
retry_before: Optional[datetime] = None,
) -> bool:
s = self._slots.get(slot_id)
retry_allowed = (
retry_before is not None
and s is not None
and s.cancel_state in {"rejected", "unknown"}
and s.cancel_attempts < 4
and s.cancel_requested_at is not None
and s.cancel_requested_at <= retry_before
)
if (
s is None
or s.broker_order_id != broker_order_id
or s.status not in self._PENDING
or (s.cancel_state is not None and not retry_allowed)
):
return False
now = datetime.now(tz=timezone.utc)
self._slots[slot_id] = replace(
s,
cancel_state="unknown",
cancel_requested_at=now,
cancel_attempts=s.cancel_attempts + 1,
updated_at=now,
)
return True
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
return [
@@ -266,6 +305,9 @@ class InMemorySlotRepository:
broker_order_id=None,
cancel_state=None,
cancel_requested_at=None,
order_submitted_at=None,
order_date=None,
cancel_attempts=0,
updated_at=datetime.now(tz=timezone.utc),
)
+8
View File
@@ -64,6 +64,11 @@ class Slot:
# across ticks and restarts.
cancel_state: Optional[str] = None
cancel_requested_at: Optional[datetime] = None
# Immutable dispatch clock and original KST order date. Unlike updated_at,
# these survive polling/cancel bookkeeping and anchor deadline/recovery.
order_submitted_at: Optional[datetime] = None
order_date: Optional[str] = None
cancel_attempts: int = 0
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
@@ -95,6 +100,9 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"venue",
"cancel_state",
"cancel_requested_at",
"order_submitted_at",
"order_date",
"cancel_attempts",
})
+18
View File
@@ -213,6 +213,9 @@ class SlotService:
venue="KRX",
cancel_state=None,
cancel_requested_at=None,
order_submitted_at=None,
order_date=None,
cancel_attempts=0,
updated_at=datetime.now(tz=timezone.utc),
)
@@ -287,6 +290,21 @@ class SlotService:
venue=new_venue,
cancel_state=None,
cancel_requested_at=None,
order_submitted_at=(
slot.order_submitted_at
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}
else None
),
order_date=(
slot.order_date
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}
else None
),
cancel_attempts=(
slot.cancel_attempts
if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}
else 0
),
updated_at=datetime.now(tz=timezone.utc),
)