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
+1
View File
@@ -0,0 +1 @@
"""Broker adapters (REQ-011/012). Implement application.broker_port.BrokerPort."""
+189
View File
@@ -0,0 +1,189 @@
"""KIS (한국투자증권) Open API broker adapter (REQ-012, dcy-003).
Implements BrokerPort against the KIS REST API, modeled on the trex-orbit
reference (app/broker/kis.py). Slice 1 validates against the KIS 모의투자
(VTS sandbox) server; real-account go-live is config-gated and out of scope.
Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash
(submit), /uapi/domestic-stock/v1/trading/inquire-daily-ccld (fill poll),
/uapi/domestic-stock/v1/trading/order-rvsecncl (cancel).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from trex_core.application.broker_port import FillStatus, OrderAck, OrderRequest
_KST = timezone(timedelta(hours=9))
_MOCK_HINTS = ("openapivts", "virtual", "mock")
def _safe_int(v: object) -> Optional[int]:
try:
return int(str(v).strip())
except (TypeError, ValueError):
return None
class KISBroker:
"""KIS REST broker adapter. Live account is gated behind `allow_live`."""
def __init__(
self,
account_ref: str,
*,
app_key: str,
app_secret: str,
base_url: str,
timeout: float = 5.0,
allow_live: bool = False,
) -> None:
self._account_ref = account_ref
self._app_key = app_key
self._app_secret = app_secret
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._token: Optional[str] = None
self._token_exp: datetime = datetime.now(tz=timezone.utc)
is_mock = any(h in self._base_url for h in _MOCK_HINTS)
if not is_mock and not allow_live:
raise RuntimeError(
"KIS live account base_url requires allow_live=True (config-gated). "
"Use the 모의투자 (openapivts) endpoint for slice-1."
)
self._mock = is_mock
def name(self) -> str:
return "kis-mock" if self._mock else "kis-live"
# --- auth ---
def _ensure_token(self) -> str:
if self._token and datetime.now(tz=timezone.utc) < self._token_exp:
return self._token
resp = httpx.post(
f"{self._base_url}/oauth2/tokenP",
json={
"grant_type": "client_credentials",
"appkey": self._app_key,
"appsecret": self._app_secret,
},
timeout=self._timeout,
)
resp.raise_for_status()
data = resp.json()
self._token = data["access_token"]
ttl = int(data.get("expires_in", 3600))
self._token_exp = datetime.now(tz=timezone.utc) + timedelta(seconds=max(60, ttl - 300))
return self._token
def _headers(self, tr_id: str) -> dict[str, str]:
return {
"authorization": f"Bearer {self._ensure_token()}",
"appkey": self._app_key,
"appsecret": self._app_secret,
"tr_id": tr_id,
"content-type": "application/json; charset=utf-8",
}
def _account(self) -> tuple[str, str]:
raw = self._account_ref.strip()
if "-" in raw:
cano, product = raw.split("-", 1)
return cano.strip(), product.strip()
digits = "".join(c for c in raw if c.isdigit())
return digits[:8], (digits[8:] or "01")
def _tr_submit(self, side: str) -> str:
if self._mock:
return "VTTC0012U" if side == "buy" else "VTTC0011U"
return "TTTC0012U" if side == "buy" else "TTTC0011U"
# --- BrokerPort ---
def submit_order(self, req: OrderRequest) -> OrderAck:
cano, product = self._account()
body = {
"CANO": cano,
"ACNT_PRDT_CD": product,
"PDNO": req.symbol,
"ORD_DVSN": req.order_type,
"ORD_QTY": str(req.qty),
"ORD_UNPR": str(req.limit_price or 0),
}
try:
resp = httpx.post(
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash",
headers=self._headers(self._tr_submit(req.side)),
json=body,
timeout=self._timeout,
)
payload = resp.json()
except (httpx.HTTPError, ValueError) as exc:
return OrderAck(accepted=False, reason=f"submit_network_error:{type(exc).__name__}",
raw={"error": str(exc)})
if str(payload.get("rt_cd")) == "0":
out = payload.get("output") or {}
odno = out.get("ODNO") or out.get("odno")
orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or ""
bid = f"{orgno}:{odno}" if orgno else str(odno)
return OrderAck(accepted=True, broker_order_id=bid, raw=payload)
return OrderAck(accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"),
raw=payload)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
cano, product = self._account()
odno = broker_order_id.split(":")[-1]
today = datetime.now(tz=_KST).strftime("%Y%m%d")
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
params = {
"CANO": cano, "ACNT_PRDT_CD": product,
"INQR_STRT_DT": today, "INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "",
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": odno,
"INQR_DVSN_3": "00", "INQR_DVSN_1": "",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
}
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
headers=self._headers(tr_id), params=params, timeout=self._timeout,
)
payload = resp.json()
except (httpx.HTTPError, ValueError) as exc:
return FillStatus(state="unknown", raw={"error": str(exc)})
rows = payload.get("output1") or []
row = next((r for r in rows if str(r.get("odno")) == odno), rows[0] if rows else None)
if row is None:
return FillStatus(state="accepted", raw=payload)
ord_qty = _safe_int(row.get("ord_qty")) or 0
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs"))
if ccld_qty <= 0:
return FillStatus(state="accepted", raw=row)
if ccld_qty >= ord_qty and ord_qty > 0:
return FillStatus(state="filled", filled_qty=ccld_qty,
avg_price=float(avg) if avg else None, raw=row)
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
avg_price=float(avg) if avg else None, raw=row)
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
cano, product = self._account()
parts = broker_order_id.split(":")
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
body = {
"CANO": cano, "ACNT_PRDT_CD": product,
"KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno,
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
}
tr_id = "VTTC0013U" if self._mock else "TTTC0013U"
try:
resp = httpx.post(
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl",
headers=self._headers(tr_id), json=body, timeout=self._timeout,
)
return str(resp.json().get("rt_cd")) == "0"
except (httpx.HTTPError, ValueError):
return False
+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
@@ -170,6 +170,48 @@ class PostgreSQLSlotRepository:
row = cur.fetchone()
return int(row[0]) if row else 0
# --- order execution queue (BAT-004 / REQ-013, option A) ---
def find_pending_for_dispatch(self) -> Optional[Slot]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
"WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NULL "
"ORDER BY updated_at LIMIT 1 FOR UPDATE SKIP LOCKED"
)
row = cur.fetchone()
return self._row_to_slot(row) if row else None
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=%s, updated_at=%s "
"WHERE slot_id=%s AND broker_order_id IS NULL",
(broker_order_id, datetime.now(tz=timezone.utc), slot_id),
)
ok = cur.rowcount == 1
conn.commit()
return ok
def list_dispatched_pending(self) -> list[Slot]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT {_SLOT_COLUMNS} FROM daily_slot "
"WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NOT NULL"
)
return [self._row_to_slot(r) for r in cur.fetchall()]
def clear_dispatch(self, slot_id: UUID) -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=NULL, updated_at=%s WHERE slot_id=%s",
(datetime.now(tz=timezone.utc), slot_id),
)
conn.commit()
def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
+64
View File
@@ -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."""
...
+125
View File
@@ -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
@@ -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] = {}
+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