feat: resolve and persist actual order venue
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user