Files
trex-core/tests/adapters/broker/test_venue.py
T

461 lines
17 KiB
Python

"""Venue parameterization tests (REQ-117 AC-1~5, REQ-118 AC-1~8, BAT-056).
All tests stub the HTTP layer (token pre-seeded, httpx faked) and run always
without KIS credentials. Tests verify additive-only changes: existing KRX
path behavior is byte-equivalent to before, and NXT path uses correct EXCG.
REQ-117 AC coverage:
AC-1: venue default KRX → test_order_request_venue_default_krx
AC-2: venue NXT → test_order_request_venue_nxt
AC-3: 기존 회귀 → all existing tests pass (run via pytest)
AC-4: 위치 인수 순서 불변 → test_order_request_positional_args_unchanged
AC-5: paper venue 무영향 → test_paper_submit_nxt_accepted, test_paper_cancel_venue_ignored
REQ-118 AC coverage:
AC-1: submit KRX EXCG → test_submit_order_krx_includes_excg
AC-2: submit NXT EXCG → test_submit_order_nxt_includes_excg
AC-3: cancel NXT EXCG → test_cancel_order_nxt_includes_excg
AC-4: poll_fill TR 미확인 → (open item; see design.md)
AC-5: get_price default J → test_get_current_price_default_market_j
AC-6: get_price UN → test_get_current_price_market_un
AC-7: 기존 회귀 EXCG 추가 → test_submit_order_krx_only_adds_excg_field
AC-8: paper cancel 정합 → test_paper_cancel_venue_ignored
"""
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, resolve_order_venue
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.application.broker_port import CancelState, OrderRequest
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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
def _live_broker() -> KISBroker:
b = KISBroker("12345678-01", app_key="k", app_secret="s",
base_url=_LIVE_URL, allow_live=True)
b._token = "tok"
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
return b
class _FakeResp:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
# ---------------------------------------------------------------------------
# REQ-117 AC-1: venue default KRX
# ---------------------------------------------------------------------------
def test_order_request_venue_default_krx():
"""REQ-117 AC-1: venue 미지정 시 기본값 KRX."""
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1)
assert req.venue == "KRX"
# ---------------------------------------------------------------------------
# REQ-117 AC-2: venue NXT
# ---------------------------------------------------------------------------
def test_order_request_venue_nxt():
"""REQ-117 AC-2: venue="NXT" 지정 시 NXT."""
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1, 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: 기존 위치 인수 순서 불변
# ---------------------------------------------------------------------------
def test_order_request_positional_args_unchanged():
"""REQ-117 AC-4: 기존 위치 인수(slot_id, side, symbol, qty) 순서 불변."""
req = OrderRequest("s1", "buy", "005930", 10)
assert req.slot_id == "s1"
assert req.side == "buy"
assert req.symbol == "005930"
assert req.qty == 10
assert req.limit_price is None
assert req.order_type == "00"
assert req.venue == "KRX" # 기본값
def test_order_request_full_positional():
"""기존 위치 인수 + 키워드 혼용 가능 확인."""
req = OrderRequest("s1", "sell", "000660", 5, 50000, "00", "NXT")
assert req.slot_id == "s1"
assert req.side == "sell"
assert req.symbol == "000660"
assert req.qty == 5
assert req.limit_price == 50000
assert req.order_type == "00"
assert req.venue == "NXT"
# ---------------------------------------------------------------------------
# REQ-117 AC-5 + REQ-118 AC-8: paper broker venue 무영향
# ---------------------------------------------------------------------------
def test_paper_submit_nxt_accepted():
"""REQ-117 AC-5: paper broker는 NXT든 KRX든 venue 무관하게 주문 수락."""
b = PaperBroker(fill_after_polls=0)
req_nxt = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1, venue="NXT")
req_krx = OrderRequest(slot_id="s2", side="buy", symbol="005930", qty=1, venue="KRX")
ack_nxt = b.submit_order(req_nxt)
ack_krx = b.submit_order(req_krx)
assert ack_nxt.accepted
assert ack_krx.accepted
def test_paper_cancel_venue_ignored():
"""REQ-117 AC-5 + REQ-118 AC-8: paper cancel_order는 venue 파라미터를 받되 무시한다."""
b = PaperBroker(fill_after_polls=1)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=5)
ack = b.submit_order(req)
# NXT venue로 cancel — paper는 venue 무관하게 취소 성공
result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=5, venue="NXT")
assert result.state is CancelState.REQUESTED
def test_paper_cancel_default_venue():
"""REQ-118 AC-8: venue 미지정(기본 KRX) cancel도 동작 유지."""
b = PaperBroker(fill_after_polls=1)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=3)
ack = b.submit_order(req)
result = b.cancel_order(ack.broker_order_id, side="buy", symbol="005930", qty=3)
assert result.state is CancelState.REQUESTED
# ---------------------------------------------------------------------------
# REQ-118 AC-1: submit_order KRX EXCG 포함
# ---------------------------------------------------------------------------
def test_submit_order_krx_includes_excg(monkeypatch):
"""REQ-118 AC-1: venue="KRX"(기본) submit 시 EXCG_ID_DVSN_CD="KRX" 포함."""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0", "output": {"ODNO": "0001", "KRX_FWDG_ORD_ORGNO": "06010"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1, limit_price=10000)
b.submit_order(req)
assert captured["body"]["EXCG_ID_DVSN_CD"] == "KRX"
# ---------------------------------------------------------------------------
# REQ-118 AC-2: submit_order NXT EXCG 포함
# ---------------------------------------------------------------------------
def test_submit_order_nxt_includes_excg(monkeypatch):
"""REQ-118 AC-2: venue="NXT" submit 시 EXCG_ID_DVSN_CD="NXT" 포함."""
captured = {}
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"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=1,
limit_price=10000, venue="NXT")
b.submit_order(req)
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
# ---------------------------------------------------------------------------
# REQ-118 AC-3: cancel_order NXT EXCG 포함
# ---------------------------------------------------------------------------
def test_cancel_order_nxt_includes_excg(monkeypatch):
"""REQ-118 AC-3: venue="NXT" cancel 시 EXCG_ID_DVSN_CD="NXT" 포함."""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0"})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
b.cancel_order("06010:0000123456", side="sell", symbol="005930", qty=10, venue="NXT")
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
def test_cancel_order_krx_includes_excg(monkeypatch):
"""cancel_order 기본(KRX) 시 EXCG_ID_DVSN_CD="KRX" 포함."""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0"})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
b.cancel_order("06010:0000123456", side="sell", symbol="005930", qty=10)
assert captured["body"]["EXCG_ID_DVSN_CD"] == "KRX"
def test_cancel_explicit_broker_rejection_is_distinct(monkeypatch):
def fake_post(url, headers=None, json=None, timeout=None):
return _FakeResp({"rt_cd": "1", "msg_cd": "ORDER_NOT_CANCELLABLE"})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
outcome = b.cancel_order(
"06010:0000123456",
side="buy",
symbol="005930",
qty=1,
)
assert outcome.state is CancelState.REJECTED
assert outcome.reason == "ORDER_NOT_CANCELLABLE"
def test_cancel_transport_loss_is_unknown(monkeypatch):
def fake_post(url, headers=None, json=None, timeout=None):
raise httpx.ReadTimeout("timed out")
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
outcome = b.cancel_order(
"06010:0000123456",
side="buy",
symbol="005930",
qty=1,
)
assert outcome.state is CancelState.UNKNOWN
assert outcome.reason == "transport_failed"
# ---------------------------------------------------------------------------
# REQ-118 AC-5: get_current_price 기본 market=J
# ---------------------------------------------------------------------------
def test_get_current_price_default_market_j(monkeypatch):
"""REQ-118 AC-5: market 미지정 시 FID_COND_MRKT_DIV_CODE=J 전달."""
captured = {}
def fake_get(url, headers=None, params=None, timeout=None):
captured["params"] = params
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": "49000"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "get", fake_get)
price = b.get_current_price("005930")
assert price == Decimal("49000")
assert captured["params"]["FID_COND_MRKT_DIV_CODE"] == "J"
# ---------------------------------------------------------------------------
# REQ-118 AC-6: get_current_price market=UN
# ---------------------------------------------------------------------------
def test_get_current_price_market_un(monkeypatch):
"""REQ-118 AC-6: market="UN" 지정 시 FID_COND_MRKT_DIV_CODE=UN 전달."""
captured = {}
def fake_get(url, headers=None, params=None, timeout=None):
captured["params"] = params
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": "150000"}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "get", fake_get)
price = b.get_current_price("005930", market="UN")
assert price == Decimal("150000")
assert captured["params"]["FID_COND_MRKT_DIV_CODE"] == "UN"
# ---------------------------------------------------------------------------
# REQ-118 AC-7: 기존 회귀 — venue 미지정 submit 페이로드는 EXCG 추가만 다름
# ---------------------------------------------------------------------------
def test_submit_order_krx_only_adds_excg_field(monkeypatch):
"""REQ-118 AC-7: venue 미지정 submit body는 기존 대비 EXCG_ID_DVSN_CD="KRX" 추가만 다름.
기존 submit body fields: CANO, ACNT_PRDT_CD, PDNO, ORD_DVSN, ORD_QTY, ORD_UNPR
신규: 위 6개 + EXCG_ID_DVSN_CD="KRX"
"""
captured = {}
def fake_post(url, headers=None, json=None, timeout=None):
captured["body"] = json
return _FakeResp({"rt_cd": "0", "output": {"ODNO": "9999", "KRX_FWDG_ORD_ORGNO": ""}})
b = _mock_broker()
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
req = OrderRequest(slot_id="s1", side="buy", symbol="005930", qty=2, limit_price=50000)
b.submit_order(req)
body = captured["body"]
# 기존 6개 필드 존재 확인
assert body["CANO"] == "12345678"
assert body["ACNT_PRDT_CD"] == "01"
assert body["PDNO"] == "005930"
assert body["ORD_DVSN"] == "00"
assert body["ORD_QTY"] == "2"
assert body["ORD_UNPR"] == "50000"
# 신규 추가 필드 — EXCG_ID_DVSN_CD="KRX"만 다름
assert body["EXCG_ID_DVSN_CD"] == "KRX"
# 7개 필드만 있음 (다른 필드 추가 없음)
assert set(body.keys()) == {
"CANO", "ACNT_PRDT_CD", "PDNO", "ORD_DVSN",
"ORD_QTY", "ORD_UNPR", "EXCG_ID_DVSN_CD",
}
# ---------------------------------------------------------------------------
# BrokerPort 프로토콜 정합 검증
# ---------------------------------------------------------------------------
def test_kis_broker_conforms_to_broker_port():
"""KISBroker가 BrokerPort 프로토콜을 만족하는지 구조적 확인."""
import inspect
# submit_order 시그니처: req: OrderRequest
sig = inspect.signature(KISBroker.submit_order)
assert "req" in sig.parameters
# cancel_order 시그니처: venue 파라미터 있음, 기본값 KRX
sig = inspect.signature(KISBroker.cancel_order)
assert "venue" in sig.parameters
assert sig.parameters["venue"].default == "KRX"
# get_current_price 시그니처: market 파라미터 있음, 기본값 J
sig = inspect.signature(KISBroker.get_current_price)
assert "market" in sig.parameters
assert sig.parameters["market"].default == "J"
def test_paper_broker_conforms_to_broker_port():
"""PaperBroker가 BrokerPort 프로토콜을 만족하는지 구조적 확인."""
import inspect
sig = inspect.signature(PaperBroker.cancel_order)
assert "venue" in sig.parameters
assert sig.parameters["venue"].default == "KRX"
sig = inspect.signature(PaperBroker.get_current_price)
assert "market" in sig.parameters
assert sig.parameters["market"].default == "J"
# ---------------------------------------------------------------------------
# 기존 cancel_order 호출자 호환: venue 없이 호출 가능
# ---------------------------------------------------------------------------
def test_paper_cancel_backward_compat_no_venue():
"""기존 코드가 venue 없이 cancel_order를 호출해도 동작한다(기본값 KRX)."""
b = PaperBroker(fill_after_polls=1)
req = OrderRequest(slot_id="s1", side="sell", symbol="005930", qty=2)
ack = b.submit_order(req)
# 기존 스타일 호출 (venue 없음)
result = b.cancel_order(ack.broker_order_id, side="sell", symbol="005930", qty=2)
assert result.state is CancelState.REQUESTED