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:
@@ -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
|
||||
Reference in New Issue
Block a user