feat(execution): reconcile timed-out orders safely
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user