Deliver BAT-004: broker execution + KIS adapter + durable handoff

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>
This commit is contained in:
2026-06-04 17:46:17 +09:00
co-authored by Claude Opus 4.8
parent d60d720bc7
commit 97d58318db
29 changed files with 1456 additions and 35 deletions
@@ -7,6 +7,7 @@ from __future__ import annotations
import threading
from contextlib import contextmanager
from dataclasses import replace
from datetime import date, datetime, timezone
from typing import Iterator, Optional
from uuid import UUID, uuid4
@@ -103,6 +104,41 @@ class InMemorySlotRepository:
count, _ = self._entries.get((trading_date, symbol), (0, datetime.min))
return count
# --- order execution queue (BAT-004 / REQ-013, option A) ---
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
def find_pending_for_dispatch(self) -> Optional[Slot]:
"""A pending slot not yet dispatched (broker_order_id IS NULL)."""
for s in self._slots.values():
if s.status in self._PENDING and s.broker_order_id is None:
return s
return None
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
"""Atomically set broker_order_id iff still undispatched (idempotency guard)."""
s = self._slots.get(slot_id)
if s is None or s.broker_order_id is not None:
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) -> list[Slot]:
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
return [
s for s in self._slots.values()
if s.status in self._PENDING and s.broker_order_id is not None
]
def clear_dispatch(self, slot_id: UUID) -> None:
"""Reset broker_order_id so the slot can be (re)dispatched (e.g. after a buy fill)."""
s = self._slots.get(slot_id)
if s is not None:
self._slots[slot_id] = replace(
s, broker_order_id=None, updated_at=datetime.now(tz=timezone.utc)
)
# --- bulk counts ---
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
result: dict[SlotState, int] = {}