fix: reconcile ambiguous KIS order submissions

This commit is contained in:
jordan
2026-07-28 11:17:06 +09:00
parent b72bf9f394
commit 24c42d1c92
11 changed files with 1420 additions and 191 deletions
+63 -28
View File
@@ -3,6 +3,7 @@
Simulates advisory lock via threading.Lock so contention tests are still
meaningful even without a real PostgreSQL instance.
"""
from __future__ import annotations
import threading
@@ -51,11 +52,10 @@ class InMemorySlotRepository:
"""Test seeding helper — insert a slot row directly."""
self._slots[slot.slot_id] = slot
def list_slots(
self, instance_key: str, status_filter: Optional[set[str]] = None
) -> list[Slot]:
def list_slots(self, instance_key: str, status_filter: Optional[set[str]] = None) -> list[Slot]:
return [
s for s in self._slots.values()
s
for s in self._slots.values()
if s.instance_key == instance_key
and (status_filter is None or s.status.value in status_filter)
]
@@ -79,52 +79,62 @@ class InMemorySlotRepository:
def count_active(self, instance_key: str) -> int:
return sum(
1 for s in self._slots.values()
1
for s in self._slots.values()
if s.instance_key == instance_key and s.status in self._ACTIVE
)
def list_holding_slots(self, instance_key: str) -> list[Slot]:
return [
s for s in self._slots.values()
s
for s in self._slots.values()
if s.instance_key == instance_key and s.status is SlotState.HOLDING
]
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]:
candidates = [
s for s in self._slots.values()
if s.instance_key == instance_key and s.symbol == symbol
s for s in self._slots.values() if s.instance_key == instance_key and s.symbol == symbol
]
# PostgreSQL parity: updated_at DESC NULLS LAST, slot_id ASC.
candidates.sort(key=lambda s: (
s.updated_at is None,
-s.updated_at.timestamp() if s.updated_at is not None else 0,
str(s.slot_id),
))
candidates.sort(
key=lambda s: (
s.updated_at is None,
-s.updated_at.timestamp() if s.updated_at is not None else 0,
str(s.slot_id),
)
)
return candidates[0] if candidates else None
def reconcile_universe(
self, instance_key: str, universe: dict[str, str],
*, allow_retire: bool = True,
self,
instance_key: str,
universe: dict[str, str],
*,
allow_retire: bool = True,
) -> dict[str, int]:
existing = {
s.portfolio_id: s for s in self._slots.values()
s.portfolio_id: s
for s in self._slots.values()
if s.instance_key == instance_key and s.portfolio_id is not None
}
now = datetime.now(tz=timezone.utc)
created = retired = tombstoned = 0
# backfill symbol on slots that were created before this field was populated
for s in self._slots.values():
if (s.instance_key == instance_key and s.portfolio_id in universe
and s.symbol is None):
if s.instance_key == instance_key and s.portfolio_id in universe and s.symbol is None:
self._slots[s.slot_id] = replace(s, symbol=universe[s.portfolio_id], updated_at=now)
# create asset slots for newly-appeared universe assets (idempotent)
for pid, symbol in universe.items():
if pid not in existing:
sid = uuid4()
self._slots[sid] = Slot(
slot_id=sid, status=SlotState.INACTIVE,
instance_key=instance_key, portfolio_id=pid, symbol=symbol,
created_at=now, updated_at=now,
slot_id=sid,
status=SlotState.INACTIVE,
instance_key=instance_key,
portfolio_id=pid,
symbol=symbol,
created_at=now,
updated_at=now,
)
created += 1
# retire / tombstone assets that left the universe (skipped under the
@@ -155,7 +165,9 @@ class InMemorySlotRepository:
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
def list_undispatched_pending(
self, instance_key: str = "default", limit: int = 64,
self,
instance_key: str = "default",
limit: int = 64,
) -> list[Slot]:
"""Pending slots not yet dispatched (broker_order_id IS NULL).
@@ -163,9 +175,13 @@ class InMemorySlotRepository:
never dispatch each other's orders. List-based (vs single-row claim) so
one skipped sell (broker flat) cannot head-block the dispatch queue."""
out = [
s for s in self._slots.values()
if (s.instance_key == instance_key and s.status in self._PENDING
and s.broker_order_id is None)
s
for s in self._slots.values()
if (
s.instance_key == instance_key
and s.status in self._PENDING
and s.broker_order_id is None
)
]
return out[:limit]
@@ -179,12 +195,31 @@ class InMemorySlotRepository:
)
return True
def replace_dispatch(
self,
slot_id: UUID,
expected_broker_order_id: str,
broker_order_id: str,
) -> bool:
"""Replace a result-unknown sentinel with the broker-confirmed order ID."""
s = self._slots.get(slot_id)
if s is None or s.broker_order_id != expected_broker_order_id:
return False
self._slots[slot_id] = replace(
s, broker_order_id=broker_order_id, updated_at=datetime.now(tz=timezone.utc)
)
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 [
s for s in self._slots.values()
if (s.instance_key == instance_key and s.status in self._PENDING
and s.broker_order_id is not None)
s
for s in self._slots.values()
if (
s.instance_key == instance_key
and s.status in self._PENDING
and s.broker_order_id is not None
)
]
def clear_dispatch(self, slot_id: UUID) -> None: