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,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., 장 미개장/잔고부족)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Order execution loop tests via paper broker (REQ-013, REQ-014).
|
||||
|
||||
In-memory adapters; deterministic paper double drives the full async path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 4)
|
||||
|
||||
|
||||
def _wire(broker):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
execution = ExecutionService(svc, repo, broker)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
return repo, svc, intents, execution
|
||||
|
||||
|
||||
def _buy(intents, iid="b1", symbol="005930", qty=10):
|
||||
return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000))
|
||||
|
||||
|
||||
def test_buy_lifecycle_to_holding():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||
d = _buy(intents)
|
||||
sid = d.slot_id
|
||||
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
|
||||
execution.run_once() # dispatch + poll(fill immediately)
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert slot.status is SlotState.HOLDING and slot.qty == 10 and slot.avg_price == 1000.0
|
||||
|
||||
|
||||
def test_buy_dispatch_is_idempotent_no_double_order():
|
||||
broker = PaperBroker(fill_after_polls=5) # stays accepted (not filled) across ticks
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # dispatch (1 order) + poll(accepted)
|
||||
bid1 = repo.fetch_slot(sid).broker_order_id
|
||||
execution.run_once() # must NOT re-dispatch
|
||||
bid2 = repo.fetch_slot(sid).broker_order_id
|
||||
assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order
|
||||
|
||||
|
||||
def test_buy_reject_rolls_back_to_idle():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005930"}))
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # dispatch + poll(reject)
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert slot.status is SlotState.IDLE and slot.symbol is None and slot.broker_order_id is None
|
||||
|
||||
|
||||
def test_sell_lifecycle_to_closed_bumps_entry_count():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # -> holding
|
||||
assert repo.fetch_slot(sid).status is SlotState.HOLDING
|
||||
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
|
||||
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
|
||||
execution.run_once() # dispatch sell + fill -> closed
|
||||
assert repo.fetch_slot(sid).status is SlotState.CLOSED
|
||||
assert svc.get_today_entry_count(DAY, "005930") == 0 # +1 happens on closed->idle recovery
|
||||
|
||||
|
||||
def test_reconcile_after_crash_completes_fill_without_redispatch():
|
||||
broker = PaperBroker(fill_after_polls=1)
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here
|
||||
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
|
||||
orders_before = len(broker._orders)
|
||||
execution.reconcile() # restart: re-poll -> fills, no new order
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert slot.status is SlotState.HOLDING
|
||||
assert len(broker._orders) == orders_before # reconcile did not submit a new order
|
||||
|
||||
|
||||
def test_no_idle_slots_blocks_further_buys():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||
for i in range(5):
|
||||
assert _buy(intents, iid=f"b{i}").status == "admitted"
|
||||
assert _buy(intents, iid="overflow").status == "rejected"
|
||||
Reference in New Issue
Block a user