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
+84
View File
@@ -0,0 +1,84 @@
"""Paper/stub broker — deterministic async double (REQ-011, dcy-003 OQ-2).
Follows the same submit -> poll contract as the real KIS adapter (no
synchronous shortcut), but lets tests program WHEN/whether fills happen:
immediate, after N polls, partial, or reject. This exercises the same
execution code path that production (KIS) takes.
"""
from __future__ import annotations
import threading
from typing import Optional
from trex_core.application.broker_port import FillStatus, OrderAck, OrderRequest
class _Order:
def __init__(self, oid: str, req: OrderRequest) -> None:
self.oid = oid
self.side = req.side
self.symbol = req.symbol
self.qty = req.qty
self.price = float(req.limit_price) if req.limit_price is not None else 1000.0
self.polls = 0
self.filled = 0
self.cancelled = False
class PaperBroker:
"""Deterministic async paper broker.
- fill_after_polls: number of poll_fill calls before fills begin (0 => fills on first poll)
- qty_per_poll: filled quantity per poll once filling (None => fill remaining at once)
- reject_symbols: symbols whose orders are rejected on first poll
"""
def __init__(
self,
*,
fill_after_polls: int = 0,
qty_per_poll: Optional[int] = None,
reject_symbols: Optional[set[str]] = None,
) -> None:
self._fill_after = fill_after_polls
self._qty_per_poll = qty_per_poll
self._reject = reject_symbols or set()
self._orders: dict[str, _Order] = {}
self._seq = 0
self._lock = threading.Lock()
def name(self) -> str:
return "paper"
def submit_order(self, req: OrderRequest) -> OrderAck:
with self._lock:
self._seq += 1
oid = f"PAPER-{self._seq:06d}"
self._orders[oid] = _Order(oid, req)
return OrderAck(accepted=True, broker_order_id=oid)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
with self._lock:
o = self._orders.get(broker_order_id)
if o is None:
return FillStatus(state="unknown")
if o.cancelled:
return FillStatus(state="cancelled")
o.polls += 1
if symbol in self._reject:
return FillStatus(state="rejected", raw={"reason": "paper_reject"})
if o.polls <= self._fill_after:
return FillStatus(state="accepted", filled_qty=o.filled, avg_price=o.price)
step = self._qty_per_poll if self._qty_per_poll is not None else (o.qty - o.filled)
o.filled = min(o.qty, o.filled + step)
if o.filled >= o.qty:
return FillStatus(state="filled", filled_qty=o.filled, avg_price=o.price)
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
with self._lock:
o = self._orders.get(broker_order_id)
if o is None or o.filled >= o.qty:
return False
o.cancelled = True
return True