fix: reconcile ambiguous KIS order submissions
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user