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
View File
+77
View File
@@ -0,0 +1,77 @@
"""KIS adapter tests (REQ-012).
Unit tests run always; the sandbox integration test runs only when KIS
모의투자 credentials are present (TREX_KIS_BROKER_* env / .env).
"""
from __future__ import annotations
import os
import pytest
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.broker_port import OrderRequest
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
def test_account_split_and_mock_tr_ids():
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
assert b._account() == ("12345678", "01")
assert b._tr_submit("buy") == "VTTC0012U"
assert b._tr_submit("sell") == "VTTC0011U"
assert b.name() == "kis-mock"
def test_live_base_url_requires_allow_live():
with pytest.raises(RuntimeError):
KISBroker("12345678-01", app_key="k", app_secret="s",
base_url="https://openapi.koreainvestment.com:9443")
# gated on explicitly
b = KISBroker("12345678-01", app_key="k", app_secret="s",
base_url="https://openapi.koreainvestment.com:9443", allow_live=True)
assert b.name() == "kis-live" and b._tr_submit("buy") == "TTTC0012U"
def _kis_env():
keys = ("TREX_KIS_BROKER_BASE_URL", "TREX_KIS_BROKER_APP_KEY",
"TREX_KIS_BROKER_APP_SECRET", "TREX_KIS_BROKER_ACCOUNT_REF")
if not all(os.environ.get(k) for k in keys):
# try .env via dotenv
try:
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
except ImportError:
pass
if not all(os.environ.get(k) for k in keys):
pytest.skip("KIS 모의투자 credentials not set; skipping sandbox integration test")
return {k: os.environ[k] for k in keys}
def test_sandbox_token_and_submit():
"""Single sandbox round-trip (one token issuance — KIS rate-limits token requests)."""
import httpx
env = _kis_env()
assert "openapivts" in env["TREX_KIS_BROKER_BASE_URL"], "expect 모의투자 sandbox URL"
b = KISBroker(
env["TREX_KIS_BROKER_ACCOUNT_REF"],
app_key=env["TREX_KIS_BROKER_APP_KEY"],
app_secret=env["TREX_KIS_BROKER_APP_SECRET"],
base_url=env["TREX_KIS_BROKER_BASE_URL"],
timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "10")),
)
try:
token = b._ensure_token()
except httpx.HTTPStatusError as exc: # KIS EGW00133: token issuance rate-limited
pytest.skip(f"KIS token rate-limited/unavailable: {exc}")
assert isinstance(token, str) and len(token) > 0
# limit buy 1 share at a low price (sandbox, no real money). Reuses the cached token.
ack = b.submit_order(OrderRequest(slot_id="t", side="buy", symbol="005930", qty=1,
limit_price=10000, order_type="00"))
assert ack.raw, "expected a KIS response payload"
if ack.accepted:
assert ack.broker_order_id
else:
assert ack.reason # rejection carries a reason (e.g., 장 미개장/잔고부족)
+50
View File
@@ -0,0 +1,50 @@
"""Paper broker deterministic async double tests (REQ-011)."""
from __future__ import annotations
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.application.broker_port import OrderRequest
def _req(symbol="005930", qty=10, price=1000):
return OrderRequest(slot_id="s1", side="buy", symbol=symbol, qty=qty, limit_price=price)
def test_submit_does_not_synchronously_fill():
b = PaperBroker(fill_after_polls=1)
ack = b.submit_order(_req())
assert ack.accepted and ack.broker_order_id
# next poll is still 'accepted' (async contract, no synchronous shortcut)
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert fs.state == "accepted" and fs.filled_qty == 0
def test_fills_after_configured_polls():
b = PaperBroker(fill_after_polls=1)
ack = b.submit_order(_req())
b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # poll 1 -> accepted
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # poll 2 -> filled
assert fs.is_filled and fs.filled_qty == 10 and fs.avg_price == 1000.0
def test_immediate_fill_on_first_poll():
b = PaperBroker(fill_after_polls=0)
ack = b.submit_order(_req())
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert fs.is_filled
def test_partial_then_full():
b = PaperBroker(fill_after_polls=0, qty_per_poll=4)
ack = b.submit_order(_req(qty=10))
f1 = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert f1.state == "partially_filled" and f1.filled_qty == 4
b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # 8
f3 = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # 10
assert f3.is_filled and f3.filled_qty == 10
def test_reject():
b = PaperBroker(reject_symbols={"005930"})
ack = b.submit_order(_req())
fs = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert fs.state == "rejected"