- OrderRequest.venue: str = "KRX" additive 추가 (기존 위치 인수 순서 불변) - BrokerPort.cancel_order: venue: str = "KRX" 파라미터 추가 - BrokerPort.get_current_price: market: str = "J" 파라미터 추가 - KISBroker.submit_order: EXCG_ID_DVSN_CD=req.venue 항상 명시 전송 - KISBroker.cancel_order: EXCG_ID_DVSN_CD=venue 항상 명시 전송 - KISBroker.get_current_price: FID_COND_MRKT_DIV_CODE=market 파라미터화 - PaperBroker: cancel_order/get_current_price 시그니처 정합 (venue/market 수신·무시) - poll_fill TR 유지 (TTTC8001R) — NXT 체결 포함 여부 미확인 open item (코드 주석 기록) - tests/adapters/broker/test_venue.py: REQ-117 AC-1~5 + REQ-118 AC-1~8 신규 17개 - docs: interface-contract.md OrderRequest/BrokerPort 섹션 + change log 추가 - docs: runtime-flows.md venue 전달 경로 명시 + change log 추가 결과: 794 passed, 47 skipped (기존 777+47 대비 신규 17개 추가 전부 통과) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
343 lines
14 KiB
Python
343 lines
14 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
|
|
from trex_core.adapters.broker.paper import PaperBroker
|
|
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
|
|
|
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
|
_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)
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 is True
|
|
|
|
|
|
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 is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 is True
|