feat: resolve and persist actual order venue

This commit is contained in:
jordan
2026-07-29 17:10:32 +09:00
parent 6398ff250c
commit a656350a35
18 changed files with 304 additions and 47 deletions
+88 -5
View File
@@ -26,13 +26,12 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import httpx
import pytest
from trex_core.adapters.broker import kis as kis_mod
from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.broker.kis import KISBroker, resolve_order_venue
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.application.broker_port import BrokerPort, OrderRequest
from trex_core.application.broker_port import OrderRequest
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
@@ -42,8 +41,14 @@ _LIVE_URL = "https://openapi.koreainvestment.com:9443"
# Helpers
# ---------------------------------------------------------------------------
def _mock_broker() -> KISBroker:
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
def _mock_broker(*, clock=None) -> KISBroker:
b = KISBroker(
"12345678-01",
app_key="k",
app_secret="s",
base_url=_MOCK_URL,
clock=clock,
)
b._token = "tok"
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
return b
@@ -85,6 +90,84 @@ def test_order_request_venue_nxt():
assert req.venue == "NXT"
@pytest.mark.parametrize(
("at", "expected"),
[
(datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
(datetime(2026, 7, 29, 8, 49, 59, tzinfo=timezone(timedelta(hours=9))), "NXT"),
(datetime(2026, 7, 29, 9, 0, 30, tzinfo=timezone(timedelta(hours=9))), "KRX"),
(datetime(2026, 7, 29, 15, 29, 59, tzinfo=timezone(timedelta(hours=9))), "KRX"),
(datetime(2026, 7, 29, 15, 30, tzinfo=timezone(timedelta(hours=9))), "NXT"),
(datetime(2026, 7, 29, 20, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
],
)
def test_auto_venue_resolves_by_kst_session(at, expected):
assert resolve_order_venue("AUTO", at) == expected
@pytest.mark.parametrize(
"at",
[
datetime(2026, 7, 29, 7, 59, 59, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 8, 50, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 9, 0, 29, tzinfo=timezone(timedelta(hours=9))),
datetime(2026, 7, 29, 20, 0, 1, tzinfo=timezone(timedelta(hours=9))),
],
)
def test_auto_venue_fails_closed_outside_supported_sessions(at):
with pytest.raises(ValueError, match="auto_venue_session_closed"):
resolve_order_venue("AUTO", at)
def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
captured = {}
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp(
{"rt_cd": "0", "output": {"ODNO": "0002", "KRX_FWDG_ORD_ORGNO": "06010"}}
)
broker = _mock_broker(clock=lambda: at)
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
ack = broker.submit_order(
OrderRequest(
slot_id="s-auto",
side="buy",
symbol="005930",
qty=1,
venue="AUTO",
)
)
assert ack.accepted is True
assert ack.venue == "NXT"
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
def test_submit_auto_outside_session_rejects_without_http(monkeypatch):
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
broker = _mock_broker(clock=lambda: at)
def unexpected_post(*args, **kwargs):
raise AssertionError("AUTO session rejection must happen before HTTP")
monkeypatch.setattr(kis_mod.httpx, "post", unexpected_post)
ack = broker.submit_order(
OrderRequest(
slot_id="s-auto-closed",
side="buy",
symbol="005930",
qty=1,
venue="AUTO",
)
)
assert ack.accepted is False
assert ack.reason == "auto_venue_session_closed"
# ---------------------------------------------------------------------------
# REQ-117 AC-4: 기존 위치 인수 순서 불변
# ---------------------------------------------------------------------------
@@ -20,7 +20,7 @@ def _trade(execution_id="paper:1:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="ORD-1", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc), venue="NXT",
)
@@ -32,6 +32,7 @@ def test_enqueue_persists_and_is_idempotent(pg_clean):
pending = outbox.list_pending()
assert len(pending) == 1
assert pending[0].status is WorkItemStatus.PENDING
assert pending[0].trade.venue == "NXT"
def test_writer_completes_persisted_item(pg_clean):
@@ -59,6 +59,7 @@ def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it():
assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0)
assert t.execution_id.startswith("paper:PAPER-")
assert t.fee > 0.0 and t.tax == 0.0
assert t.venue == "AUTO"
def test_sell_fill_enqueues_sell_ledger_item():
+2 -2
View File
@@ -43,8 +43,8 @@ class _RecordingBroker:
def poll_fill(self, broker_order_id, *, side, symbol):
return self.responder(side)
def cancel_order(self, broker_order_id, *, side, symbol, qty):
self.cancel_calls.append((broker_order_id, side, symbol, qty))
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
self.cancel_calls.append((broker_order_id, side, symbol, qty, venue))
if self.cancel_raises:
raise RuntimeError("broker error")
return True
+69 -3
View File
@@ -1,10 +1,12 @@
"""Venue propagation from the strategy wire contract to broker execution."""
from __future__ import annotations
from uuid import UUID
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck, OrderRequest
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import IntentService
from trex_core.application.intents import BuyIntent, IntentService
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
@@ -12,13 +14,18 @@ from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
class RecordingBroker:
def __init__(self) -> None:
def __init__(self, *, actual_venue: str | None = None) -> None:
self.orders: list[OrderRequest] = []
self.cancel_venues: list[str] = []
self.actual_venue = actual_venue
def submit_order(self, req: OrderRequest) -> OrderAck:
self.orders.append(req)
return OrderAck(accepted=True, broker_order_id=f"order-{len(self.orders)}")
return OrderAck(
accepted=True,
broker_order_id=f"order-{len(self.orders)}",
venue=self.actual_venue or req.venue,
)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
return FillStatus(state="accepted")
@@ -104,6 +111,41 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
assert repo.list_slots("default")[0].status is SlotState.INACTIVE
def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
repo = InMemorySlotRepository()
service = SlotService(repo)
intents = IntentService(
service,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda portfolio_id: "005930",
)
repo.reconcile_universe("default", {"PF-1": "005930"})
broker = RecordingBroker(actual_venue="NXT")
execution = ExecutionService(service, repo, broker)
handler = make_intent_rpc_handler(intents)
response = handler(
RpcRequest(
payload={
"intent_type": "buy",
"intent_id": "auto-buy",
"portfolio_id": "PF-1",
"qty": 1,
},
correlation_id="auto-buy",
)
)
slot_id = UUID(response.payload["slot_id"])
pending = repo.fetch_slot(slot_id)
assert pending is not None and pending.venue == "AUTO"
execution.run_once()
dispatched = repo.fetch_slot(slot_id)
assert broker.orders[0].venue == "AUTO"
assert dispatched is not None and dispatched.venue == "NXT"
def test_inactive_transition_resets_venue_to_krx() -> None:
repo, intents, _, _ = _wire()
handler = make_intent_rpc_handler(intents)
@@ -131,6 +173,30 @@ def test_inactive_transition_resets_venue_to_krx() -> None:
assert reset.venue == "KRX"
def test_holding_transition_resets_next_order_venue_to_auto() -> None:
repo, intents, _, _ = _wire()
decision = intents.submit_buy(
BuyIntent(
intent_id="nxt-buy-holding",
portfolio_id="PF-1",
qty=1,
venue="NXT",
)
)
slot = repo.fetch_slot(decision.slot_id)
service = SlotService(repo)
holding = service.transit_slot(
slot.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="fill",
actor="test",
qty=1,
avg_price=1000,
)
assert holding.venue == "AUTO"
def test_operator_cancel_uses_original_nxt_venue() -> None:
repo, intents, execution, broker = _wire()
handler = make_intent_rpc_handler(intents)