Implement REQ-011~015 (dcy-003): the order-execution layer on top of the BAT-002 slot-core daemon, connecting intents to real orders. - application: BrokerPort (submit/poll_fill/cancel) + ExecutionService (claim -> submit -> poll -> transition -> rollback -> reconcile) - adapters/broker: PaperBroker (deterministic async double) and KISBroker (KIS Open API: token, order-cash, inquire-daily-ccld, cancel; VTS sandbox tr_ids; live account gated behind allow_live) - persistence: option-A execution queue (find_pending_for_dispatch FOR UPDATE SKIP LOCKED, mark_dispatched idempotency guard, clear_dispatch) - runtime: background execution loop in the daemon lifespan + startup reconcile; config selects paper|kis Verification (real KIS VTS 모의투자 + PostgreSQL): 92 passed, ruff clean, domain import sweep shows zero broker/web/db deps, buy->holding / sell->closed / reject->idle, idempotent dispatch (exactly one order), reconcile after crash creates no duplicate order, KIS sandbox token+submit round-trip, and a daemon E2E through the background loop. Align the four baseline docs. BAT-004 -> release-candidate; REQ-011~015 -> Implemented. KIS live-account go-live, partial-fill accrual, option-B outbox, connection pooling, and the safety floor remain deferred to follow-up Discoveries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""PostgreSQL persistence adapters (REQ-006).
|
|
|
|
- PostgreSQLSlotRepository: implements the domain SlotRepository protocol
|
|
(single-writer, pg_advisory_xact_lock serialization).
|
|
- PostgreSQLIntentStore: implements the application IntentStore port
|
|
(intent_log dedup gate + audit).
|
|
|
|
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
|
|
from contextlib import contextmanager
|
|
from datetime import date, datetime, timezone
|
|
from typing import Any, Iterator, Optional
|
|
from uuid import UUID, uuid4
|
|
|
|
from trex_core.application.ports import IntentRecord
|
|
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
|
|
|
_SLOT_COLUMNS = (
|
|
"slot_id, trading_date, status, symbol, qty, avg_price, entry_price, "
|
|
"allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, "
|
|
"sell_strategy_id, forced_close_at, created_at, updated_at"
|
|
)
|
|
|
|
|
|
def _require_psycopg(module: Any = None) -> Any:
|
|
if module is not None:
|
|
return module
|
|
try:
|
|
import psycopg # type: ignore[import-not-found]
|
|
except ImportError as exc: # pragma: no cover - environment guard
|
|
raise RuntimeError(
|
|
"psycopg is required for PostgreSQL adapters. Install 'trex-core[postgres]'."
|
|
) from exc
|
|
return psycopg
|
|
|
|
|
|
class PostgreSQLSlotRepository:
|
|
"""psycopg-backed SlotRepository. Connection-per-call (orbit-proven)."""
|
|
|
|
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
|
self._psycopg = _require_psycopg(psycopg_module)
|
|
self._dsn = dsn
|
|
|
|
@contextmanager
|
|
def transactional_advisory_lock(self, lock_id: int) -> Iterator[None]:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute("SELECT pg_advisory_xact_lock(%s)", (lock_id,))
|
|
yield
|
|
conn.commit()
|
|
except BaseException:
|
|
conn.rollback()
|
|
raise
|
|
|
|
def pre_allocate_slots(self, trading_date: date, count: int) -> int:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT COUNT(*) FROM daily_slot WHERE trading_date = %s", (trading_date,)
|
|
)
|
|
row = cur.fetchone()
|
|
if row and row[0] > 0:
|
|
conn.commit()
|
|
return 0
|
|
now = datetime.now(tz=timezone.utc)
|
|
rows = [
|
|
(uuid4(), trading_date, SlotState.IDLE.value, now, now) for _ in range(count)
|
|
]
|
|
cur.executemany(
|
|
"INSERT INTO daily_slot (slot_id, trading_date, status, created_at, updated_at) "
|
|
"VALUES (%s, %s, %s, %s, %s)",
|
|
rows,
|
|
)
|
|
conn.commit()
|
|
return count
|
|
|
|
def list_slots(self, trading_date: date) -> list[Slot]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE trading_date = %s",
|
|
(trading_date,),
|
|
)
|
|
return [self._row_to_slot(r) for r in cur.fetchall()]
|
|
|
|
def fetch_slot(self, slot_id: UUID) -> Optional[Slot]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE slot_id = %s", (slot_id,)
|
|
)
|
|
row = cur.fetchone()
|
|
return self._row_to_slot(row) if row else None
|
|
|
|
def find_idle_slot(self, trading_date: date) -> Optional[Slot]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
|
"WHERE trading_date = %s AND status = 'idle' LIMIT 1",
|
|
(trading_date,),
|
|
)
|
|
row = cur.fetchone()
|
|
return self._row_to_slot(row) if row else None
|
|
|
|
def update_slot(self, slot: Slot) -> None:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET status=%s, symbol=%s, qty=%s, avg_price=%s, "
|
|
"entry_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, "
|
|
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
|
|
"forced_close_at=%s, updated_at=%s WHERE slot_id=%s",
|
|
(
|
|
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
|
|
slot.entry_price, slot.allocated_capital, slot.order_id,
|
|
slot.strategy_id, slot.broker_order_id, slot.portfolio_id,
|
|
slot.sell_strategy_id, slot.forced_close_at,
|
|
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
|
|
),
|
|
)
|
|
if cur.rowcount == 0:
|
|
conn.rollback()
|
|
raise SlotNotFoundError(f"slot not found: {slot.slot_id}")
|
|
conn.commit()
|
|
|
|
def append_transition_log(self, record: SlotTransitionRecord) -> None:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO slot_transition_log "
|
|
"(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),
|
|
),
|
|
)
|
|
conn.commit()
|
|
|
|
def increment_symbol_entry(self, trading_date: date, symbol: str) -> int:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO daily_symbol_entries (trading_date, symbol, entry_count, last_entry_at) "
|
|
"VALUES (%s, %s, 1, %s) "
|
|
"ON CONFLICT (trading_date, symbol) DO UPDATE "
|
|
"SET entry_count = daily_symbol_entries.entry_count + 1, "
|
|
" last_entry_at = EXCLUDED.last_entry_at RETURNING entry_count",
|
|
(trading_date, symbol, datetime.now(tz=timezone.utc)),
|
|
)
|
|
row = cur.fetchone()
|
|
conn.commit()
|
|
return int(row[0]) if row else 0
|
|
|
|
def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT entry_count FROM daily_symbol_entries "
|
|
"WHERE trading_date = %s AND symbol = %s",
|
|
(trading_date, symbol),
|
|
)
|
|
row = cur.fetchone()
|
|
return int(row[0]) if row else 0
|
|
|
|
# --- order execution queue (BAT-004 / REQ-013, option A) ---
|
|
def find_pending_for_dispatch(self) -> Optional[Slot]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
|
"WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NULL "
|
|
"ORDER BY updated_at LIMIT 1 FOR UPDATE SKIP LOCKED"
|
|
)
|
|
row = cur.fetchone()
|
|
return self._row_to_slot(row) if row else None
|
|
|
|
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
|
|
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 IS NULL",
|
|
(broker_order_id, datetime.now(tz=timezone.utc), slot_id),
|
|
)
|
|
ok = cur.rowcount == 1
|
|
conn.commit()
|
|
return ok
|
|
|
|
def list_dispatched_pending(self) -> list[Slot]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
|
|
"WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NOT NULL"
|
|
)
|
|
return [self._row_to_slot(r) for r in cur.fetchall()]
|
|
|
|
def clear_dispatch(self, slot_id: UUID) -> None:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE daily_slot SET broker_order_id=NULL, updated_at=%s WHERE slot_id=%s",
|
|
(datetime.now(tz=timezone.utc), slot_id),
|
|
)
|
|
conn.commit()
|
|
|
|
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT status, COUNT(*) FROM daily_slot WHERE trading_date = %s GROUP BY status",
|
|
(trading_date,),
|
|
)
|
|
return {SlotState(r[0]): int(r[1]) for r in cur.fetchall()}
|
|
|
|
@staticmethod
|
|
def _row_to_slot(row: tuple) -> Slot:
|
|
f = lambda v: float(v) if v is not None else None # noqa: E731
|
|
return Slot(
|
|
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
|
|
trading_date=row[1],
|
|
status=SlotState(row[2]),
|
|
symbol=row[3],
|
|
qty=f(row[4]),
|
|
avg_price=f(row[5]),
|
|
entry_price=f(row[6]),
|
|
allocated_capital=f(row[7]),
|
|
order_id=row[8],
|
|
strategy_id=row[9],
|
|
broker_order_id=row[10],
|
|
portfolio_id=row[11],
|
|
sell_strategy_id=row[12],
|
|
forced_close_at=row[13],
|
|
created_at=row[14],
|
|
updated_at=row[15],
|
|
)
|
|
|
|
|
|
class PostgreSQLIntentStore:
|
|
"""intent_log-backed IntentStore. intent_id UNIQUE is the dedup gate."""
|
|
|
|
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
|
self._psycopg = _require_psycopg(psycopg_module)
|
|
self._dsn = dsn
|
|
|
|
def try_insert(self, intent_id: str, intent_type: str, payload: dict) -> bool:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"INSERT INTO intent_log (intent_id, intent_type, payload, status) "
|
|
"VALUES (%s, %s, %s, 'processing') ON CONFLICT (intent_id) DO NOTHING",
|
|
(intent_id, intent_type, json.dumps(payload)),
|
|
)
|
|
inserted = cur.rowcount == 1
|
|
conn.commit()
|
|
return inserted
|
|
|
|
def get(self, intent_id: str) -> Optional[IntentRecord]:
|
|
with self._psycopg.connect(self._dsn) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT intent_id, intent_type, status, slot_id, reason "
|
|
"FROM intent_log WHERE intent_id = %s",
|
|
(intent_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
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]))),
|
|
reason=row[4],
|
|
)
|
|
|
|
def finalize(
|
|
self, intent_id: str, status: str, slot_id: Optional[UUID], reason: Optional[str]
|
|
) -> None:
|
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"UPDATE intent_log SET status=%s, slot_id=%s, reason=%s, updated_at=%s "
|
|
"WHERE intent_id=%s",
|
|
(status, slot_id, reason, datetime.now(tz=timezone.utc), intent_id),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
class _PgIntentRecord:
|
|
def __init__(self, intent_id, intent_type, status, slot_id, reason):
|
|
self.intent_id = intent_id
|
|
self.intent_type = intent_type
|
|
self.status = status
|
|
self.slot_id = slot_id
|
|
self.reason = reason
|