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,64 @@
|
||||
"""Broker port + DTOs (REQ-011, dcy-003).
|
||||
|
||||
The single broker boundary (invariant B): only the core, through this port,
|
||||
submits/cancels orders and observes fills. Adapters (paper, KIS) live in
|
||||
trex_core.adapters.broker. Fills are observed asynchronously via poll_fill
|
||||
(unified contract — paper does not shortcut to synchronous, dcy-003 OQ-2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderRequest:
|
||||
slot_id: str # originating slot (audit/correlation)
|
||||
side: str # 'buy' | 'sell'
|
||||
symbol: str
|
||||
qty: int
|
||||
limit_price: Optional[int] = None # None => market order
|
||||
order_type: str = "00" # KIS ORD_DVSN: 00 limit, 01 market
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderAck:
|
||||
accepted: bool
|
||||
broker_order_id: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FillStatus:
|
||||
state: str # accepted | partially_filled | filled | rejected | cancelled | unknown
|
||||
filled_qty: int = 0
|
||||
avg_price: Optional[float] = None
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_filled(self) -> bool:
|
||||
return self.state == "filled"
|
||||
|
||||
@property
|
||||
def is_terminal_reject(self) -> bool:
|
||||
return self.state in {"rejected", "cancelled"}
|
||||
|
||||
|
||||
class BrokerPort(Protocol):
|
||||
"""Single broker session boundary (invariant B)."""
|
||||
|
||||
def name(self) -> str:
|
||||
...
|
||||
|
||||
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||
"""Submit an order. Returns accepted + broker_order_id (no synchronous fill)."""
|
||||
...
|
||||
|
||||
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
|
||||
"""Observe current fill status for a previously accepted order."""
|
||||
...
|
||||
|
||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||
"""Best-effort cancel. Returns True if the broker accepted the cancel."""
|
||||
...
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Order execution service (REQ-013, REQ-014, dcy-003).
|
||||
|
||||
A single background execution loop (invariant A single writer, invariant B
|
||||
single broker session) that, per tick:
|
||||
dispatch: claim pending slots with broker_order_id IS NULL (option A queue),
|
||||
submit the order, record broker_order_id (idempotency guard).
|
||||
poll: observe fills on dispatched pending slots and transition the slot
|
||||
(pending_buy->holding / pending_sell->closed), or roll back on reject.
|
||||
reconcile() re-polls dispatched slots on restart so a crash after dispatch
|
||||
converges without duplicate orders (REQ-014).
|
||||
|
||||
All slot writes go through SlotService (advisory lock). The broker is touched
|
||||
only here, never by the domain or the HTTP API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
||||
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
|
||||
|
||||
_MAX_DISPATCH_PER_TICK = 64
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
dispatched: int = 0
|
||||
filled: int = 0
|
||||
rolled_back: int = 0
|
||||
|
||||
|
||||
class ExecutionService:
|
||||
def __init__(self, slot_service: SlotService, repo, broker: BrokerPort) -> None:
|
||||
self._svc = slot_service
|
||||
self._repo = repo
|
||||
self._broker = broker
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def run_once(self) -> ExecutionResult:
|
||||
r = ExecutionResult()
|
||||
self._dispatch(r)
|
||||
self._poll(r)
|
||||
return r
|
||||
|
||||
def reconcile(self) -> ExecutionResult:
|
||||
"""Restart recovery: re-poll dispatched-but-pending slots (REQ-014).
|
||||
|
||||
Undispatched pending slots (broker_order_id IS NULL) are simply picked
|
||||
up by the next dispatch, so a crash before dispatch causes no duplicate.
|
||||
"""
|
||||
r = ExecutionResult()
|
||||
self._poll(r)
|
||||
return r
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def _dispatch(self, r: ExecutionResult) -> None:
|
||||
for _ in range(_MAX_DISPATCH_PER_TICK):
|
||||
slot = self._repo.find_pending_for_dispatch()
|
||||
if slot is None:
|
||||
return
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
qty = int(slot.qty or 0)
|
||||
limit = int(slot.entry_price) if slot.entry_price else None
|
||||
ack = self._broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id=str(slot.slot_id),
|
||||
side=side,
|
||||
symbol=slot.symbol or "",
|
||||
qty=qty,
|
||||
limit_price=limit,
|
||||
order_type="00" if limit is not None else "01",
|
||||
)
|
||||
)
|
||||
if ack.accepted and ack.broker_order_id:
|
||||
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
|
||||
r.dispatched += 1
|
||||
else:
|
||||
self._rollback(slot, reason=ack.reason or "order_rejected")
|
||||
r.rolled_back += 1
|
||||
|
||||
def _poll(self, r: ExecutionResult) -> None:
|
||||
for slot in self._repo.list_dispatched_pending():
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
|
||||
if fs.is_filled:
|
||||
self._on_filled(slot, fs)
|
||||
r.filled += 1
|
||||
elif fs.is_terminal_reject:
|
||||
self._rollback(slot, reason=f"order_{fs.state}")
|
||||
r.rolled_back += 1
|
||||
# accepted / partially_filled -> leave for next tick
|
||||
|
||||
# ------------------------------------------------------------
|
||||
def _on_filled(self, slot, fs) -> None:
|
||||
try:
|
||||
if slot.status is SlotState.PENDING_BUY:
|
||||
self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="buy_filled", actor="execution_loop",
|
||||
avg_price=fs.avg_price, qty=float(fs.filled_qty) or slot.qty,
|
||||
)
|
||||
else: # PENDING_SELL
|
||||
self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="sell_filled", actor="execution_loop",
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
return # slot moved concurrently; next tick reconciles
|
||||
self._repo.clear_dispatch(slot.slot_id) # done with this order
|
||||
|
||||
def _rollback(self, slot, *, reason: str) -> None:
|
||||
try:
|
||||
if slot.status is SlotState.PENDING_BUY:
|
||||
self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
|
||||
reason=reason, actor="execution_loop",
|
||||
) # idle reset clears broker_order_id in the domain
|
||||
else: # PENDING_SELL rejected -> back to holding, clear order for re-dispatch
|
||||
self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||
reason=reason, actor="execution_loop",
|
||||
)
|
||||
self._repo.clear_dispatch(slot.slot_id)
|
||||
except SlotStateMismatchError:
|
||||
return
|
||||
Reference in New Issue
Block a user