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
+29 -1
View File
@@ -1,8 +1,9 @@
"""Daemon configuration resolution (REQ-005, REQ-010)."""
"""Daemon configuration resolution (REQ-005, REQ-010, REQ-012/013)."""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
def _load_dotenv() -> None:
@@ -19,12 +20,25 @@ def _load_dotenv() -> None:
load_dotenv(find_dotenv(usecwd=True), override=False)
@dataclass(frozen=True)
class KISConfig:
base_url: str
app_key: str
app_secret: str
account_ref: str
timeout: float
allow_live: bool
@dataclass(frozen=True)
class DaemonConfig:
dsn: str
total_slots: int
host: str
port: int
broker: str # 'paper' | 'kis'
execution_interval_sec: float
kis: Optional[KISConfig] = None
@staticmethod
def from_env() -> "DaemonConfig":
@@ -34,9 +48,23 @@ class DaemonConfig:
raise RuntimeError(
"TREX_POSTGRESQL_DSN is required to start the slot-core daemon."
)
broker = os.environ.get("TREX_BROKER", "paper").strip().lower()
kis: Optional[KISConfig] = None
if broker == "kis":
kis = KISConfig(
base_url=os.environ["TREX_KIS_BROKER_BASE_URL"],
app_key=os.environ["TREX_KIS_BROKER_APP_KEY"],
app_secret=os.environ["TREX_KIS_BROKER_APP_SECRET"],
account_ref=os.environ["TREX_KIS_BROKER_ACCOUNT_REF"],
timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "5")),
allow_live=os.environ.get("TREX_KIS_ALLOW_LIVE", "").lower() in ("1", "true", "yes"),
)
return DaemonConfig(
dsn=dsn,
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
host=os.environ.get("TREX_API_HOST", "127.0.0.1"),
port=int(os.environ.get("TREX_API_PORT", "8080")),
broker=broker,
execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")),
kis=kis,
)
+44 -11
View File
@@ -1,34 +1,52 @@
"""Slot-core daemon entrypoint (REQ-005).
"""Slot-core daemon entrypoint (REQ-005, REQ-012/013/014).
Builds the FastAPI app wired to PostgreSQL adapters, applies migrations and
preallocates the trading day's slots on startup (lifecycle, not a public
endpoint), then serves via uvicorn. Console script: `trex-core-daemon`.
Builds the FastAPI app wired to PostgreSQL adapters + a broker (paper|KIS),
applies migrations and preallocates slots on startup, runs a background
execution loop (claim -> submit -> poll -> transition, with restart
reconciliation), and serves the /v1 API via uvicorn.
Console script: `trex-core-daemon`.
"""
from __future__ import annotations
import threading
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI
from trex_core.adapters.api.app import create_app
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.migrate import apply_migrations
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore # noqa: F401
from trex_core.adapters.persistence.postgresql import (
PostgreSQLIntentStore,
PostgreSQLSlotRepository,
)
from trex_core.application.broker_port import BrokerPort
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import IntentService
from trex_core.domain.slot import SlotService
from trex_core.runtime.config import DaemonConfig
def _build_broker(config: DaemonConfig) -> BrokerPort:
if config.broker == "kis":
from trex_core.adapters.broker.kis import KISBroker
k = config.kis
return KISBroker(
k.account_ref, app_key=k.app_key, app_secret=k.app_secret,
base_url=k.base_url, timeout=k.timeout, allow_live=k.allow_live,
)
return PaperBroker()
def build_app(config: DaemonConfig) -> FastAPI:
"""Construct the daemon app with PostgreSQL adapters + startup lifecycle."""
slot_repo = PostgreSQLSlotRepository(config.dsn)
intent_store = PostgreSQLIntentStore(config.dsn)
slot_service = SlotService(slot_repo)
intent_service = IntentService(slot_service, slot_repo, intent_store)
broker = _build_broker(config)
execution = ExecutionService(slot_service, slot_repo, broker)
base = create_app(
slot_service=slot_service,
@@ -37,14 +55,29 @@ def build_app(config: DaemonConfig) -> FastAPI:
default_total_slots=config.total_slots,
)
stop = threading.Event()
def _loop() -> None:
while not stop.wait(config.execution_interval_sec):
try:
execution.run_once()
except Exception: # noqa: BLE001 - loop must survive transient errors
continue
@asynccontextmanager
async def lifespan(_: FastAPI):
# Startup: migrate schema, preallocate today's slots (idempotent).
apply_migrations(config.dsn)
today = datetime.now(tz=timezone.utc).date()
slot_service.preallocate_daily_slots(today, config.total_slots)
yield
# Shutdown: connection-per-call adapters hold no pooled state to close.
slot_service.preallocate_daily_slots(
datetime.now(tz=timezone.utc).date(), config.total_slots
)
execution.reconcile() # restart recovery before serving (REQ-014)
worker = threading.Thread(target=_loop, name="trex-execution", daemon=True)
worker.start()
try:
yield
finally:
stop.set()
worker.join(timeout=config.execution_interval_sec * 3)
base.router.lifespan_context = lifespan
return base