"""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