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
+98 -30
View File
@@ -9,6 +9,7 @@ psycopg (v3) is imported lazily so the domain/test paths that use the
in-memory adapters don't require it. External I/O lives here, never in the
domain layer (NFR-1).
"""
from __future__ import annotations
import json
@@ -71,9 +72,7 @@ class PostgreSQLSlotRepository:
conn.rollback()
raise
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]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
if status_filter:
@@ -147,8 +146,11 @@ class PostgreSQLSlotRepository:
return self._row_to_slot(row) if row 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]:
now = datetime.now(tz=timezone.utc)
created = retired = tombstoned = 0
@@ -216,14 +218,26 @@ class PostgreSQLSlotRepository:
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
"occupancy_seq=%s, updated_at=%s WHERE slot_id=%s",
(
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
slot.limit_price, slot.order_id,
slot.strategy_id, slot.broker_order_id, slot.portfolio_id,
slot.sell_strategy_id, slot.forced_close_at,
slot.stop_loss_price, slot.take_profit_price,
slot.instance_key, slot.retired_at,
slot.timeout_sec, slot.slippage_pct, slot.occupancy_seq,
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
slot.status.value,
slot.symbol,
slot.qty,
slot.avg_price,
slot.limit_price,
slot.order_id,
slot.strategy_id,
slot.broker_order_id,
slot.portfolio_id,
slot.sell_strategy_id,
slot.forced_close_at,
slot.stop_loss_price,
slot.take_profit_price,
slot.instance_key,
slot.retired_at,
slot.timeout_sec,
slot.slippage_pct,
slot.occupancy_seq,
slot.updated_at or datetime.now(tz=timezone.utc),
slot.slot_id,
),
)
if cur.rowcount == 0:
@@ -239,15 +253,22 @@ class PostgreSQLSlotRepository:
"(slot_id, from_state, to_state, ts, reason, actor, extra) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
(
record.slot_id, record.from_state.value, record.to_state.value,
record.ts, record.reason, record.actor, json.dumps(record.extra),
record.slot_id,
record.from_state.value,
record.to_state.value,
record.ts,
record.reason,
record.actor,
json.dumps(record.extra),
),
)
conn.commit()
# --- order execution queue (BAT-004 / REQ-013, option A) ---
def list_undispatched_pending(
self, instance_key: str = "default", limit: int = 64,
self,
instance_key: str = "default",
limit: int = 64,
) -> list[Slot]:
# dcy-020/REQ-056 (BAT-030): instance-scoped so two daemons sharing a
# DB never dispatch each other's orders. List-based (vs single-row
@@ -275,6 +296,29 @@ class PostgreSQLSlotRepository:
conn.commit()
return ok
def replace_dispatch(
self,
slot_id: UUID,
expected_broker_order_id: str,
broker_order_id: str,
) -> bool:
"""Atomically persist a broker order ID resolved after submit timeout."""
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=%s, updated_at=%s "
"WHERE slot_id=%s AND broker_order_id=%s",
(
broker_order_id,
datetime.now(tz=timezone.utc),
slot_id,
expected_broker_order_id,
),
)
ok = cur.rowcount == 1
conn.commit()
return ok
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
@@ -308,6 +352,7 @@ class PostgreSQLSlotRepository:
@staticmethod
def _row_to_slot(row: tuple) -> Slot:
from decimal import Decimal
f = lambda v: float(v) if v is not None else None # noqa: E731
d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731
# Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4)
@@ -373,8 +418,12 @@ class PostgreSQLIntentStore:
if row is None:
return None
return _PgIntentRecord(
intent_id=row[0], intent_type=row[1], status=row[2],
slot_id=(row[3] if isinstance(row[3], UUID) or row[3] is None else UUID(str(row[3]))),
intent_id=row[0],
intent_type=row[1],
status=row[2],
slot_id=(
row[3] if isinstance(row[3], UUID) or row[3] is None else UUID(str(row[3]))
),
reason=row[4],
)
@@ -426,9 +475,15 @@ class PostgreSQLLedgerOutbox:
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING",
(
trade.correlation_id, trade.asset_id, trade.order_id,
trade.execution_id, trade.side,
trade.qty, trade.price, trade.fee, trade.tax,
trade.correlation_id,
trade.asset_id,
trade.order_id,
trade.execution_id,
trade.side,
trade.qty,
trade.price,
trade.fee,
trade.tax,
trade.executed_at,
),
)
@@ -506,8 +561,13 @@ class PostgreSQLLedgerOutbox:
"last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s "
"WHERE correlation_id=%s",
(
status_value, attempts_delta, outcome.value, error, nra,
datetime.now(tz=timezone.utc), correlation_id,
status_value,
attempts_delta,
outcome.value,
error,
nra,
datetime.now(tz=timezone.utc),
correlation_id,
),
)
conn.commit()
@@ -519,8 +579,14 @@ class PostgreSQLLedgerOutbox:
def _row_to_item(row: tuple) -> LedgerWorkItem:
f = lambda v: float(v) if v is not None else 0.0 # noqa: E731
trade = LedgerTrade(
asset_id=row[1], order_id=row[2], execution_id=row[3], side=row[4],
qty=f(row[5]), price=f(row[6]), fee=f(row[7]), tax=f(row[8]),
asset_id=row[1],
order_id=row[2],
execution_id=row[3],
side=row[4],
qty=f(row[5]),
price=f(row[6]),
fee=f(row[7]),
tax=f(row[8]),
executed_at=row[9],
)
return LedgerWorkItem(
@@ -601,8 +667,12 @@ class PgStrategyStateStore:
"version = EXCLUDED.version, data = EXCLUDED.data, "
"updated_at = EXCLUDED.updated_at",
(
slot_id, strategy_id, current_occupancy_seq,
result.version, data, datetime.now(tz=timezone.utc),
slot_id,
strategy_id,
current_occupancy_seq,
result.version,
data,
datetime.now(tz=timezone.utc),
),
)
conn.commit()
@@ -641,7 +711,5 @@ class PgStrategyStateStore:
def delete_all(self, slot_id: UUID) -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,)
)
cur.execute("DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,))
conn.commit()