"""KIS adapter tests (REQ-012). Unit tests run always; the sandbox integration test runs only when KIS 모의투자 credentials are present (TREX_KIS_BROKER_* env / .env). """ from __future__ import annotations import os import httpx import pytest from trex_core.adapters.broker.kis import KISBroker, _balance_http_error_code from trex_core.application.broker_port import ( BrokerBalanceError, CancelState, OrderRequest, ) _MOCK_URL = "https://openapivts.koreainvestment.com:29443" def test_account_split_and_mock_tr_ids(): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) assert b._account() == ("12345678", "01") assert b._tr_submit("buy") == "VTTC0012U" assert b._tr_submit("sell") == "VTTC0011U" assert b.name() == "kis-mock" assert len(b.account_scope_hash()) == 64 assert "12345678" not in b.account_scope_hash() def test_account_scope_hash_separates_endpoint_and_application_identity(): mock_a = KISBroker( "12345678-01", app_key="app-a", app_secret="s", base_url=_MOCK_URL ) mock_b = KISBroker( "12345678-01", app_key="app-b", app_secret="s", base_url=_MOCK_URL ) live_a = KISBroker( "12345678-01", app_key="app-a", app_secret="s", base_url="https://openapi.koreainvestment.com:9443", allow_live=True, ) assert len({mock_a.account_scope_hash(), mock_b.account_scope_hash(), live_a.account_scope_hash()}) == 3 @pytest.mark.parametrize( ("status", "code"), [ (400, "broker_rejected"), (401, "authentication_failed"), (403, "authorization_failed"), (422, "broker_rejected"), (429, "transport_failed"), (500, "transport_failed"), (None, "broker_rejected"), ], ) def test_balance_http_status_classification(status, code): assert _balance_http_error_code(status) == code def test_live_base_url_requires_allow_live(): with pytest.raises(RuntimeError): KISBroker( "12345678-01", app_key="k", app_secret="s", base_url="https://openapi.koreainvestment.com:9443", ) # gated on explicitly b = KISBroker( "12345678-01", app_key="k", app_secret="s", base_url="https://openapi.koreainvestment.com:9443", allow_live=True, ) assert b.name() == "kis-live" and b._tr_submit("buy") == "TTTC0012U" def _kis_env(): keys = ( "TREX_KIS_BROKER_BASE_URL", "TREX_KIS_BROKER_APP_KEY", "TREX_KIS_BROKER_APP_SECRET", "TREX_KIS_BROKER_ACCOUNT_REF", ) if not all(os.environ.get(k) for k in keys): # try .env via dotenv try: from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv(usecwd=True), override=False) except ImportError: pass if not all(os.environ.get(k) for k in keys): pytest.skip("KIS 모의투자 credentials not set; skipping sandbox integration test") return {k: os.environ[k] for k in keys} def test_sandbox_token_and_submit(): """Single sandbox round-trip (one token issuance — KIS rate-limits token requests).""" import httpx env = _kis_env() assert "openapivts" in env["TREX_KIS_BROKER_BASE_URL"], "expect 모의투자 sandbox URL" b = KISBroker( env["TREX_KIS_BROKER_ACCOUNT_REF"], app_key=env["TREX_KIS_BROKER_APP_KEY"], app_secret=env["TREX_KIS_BROKER_APP_SECRET"], base_url=env["TREX_KIS_BROKER_BASE_URL"], timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "10")), ) try: token = b._ensure_token() except httpx.HTTPStatusError as exc: # KIS EGW00133: token issuance rate-limited pytest.skip(f"KIS token rate-limited/unavailable: {exc}") assert isinstance(token, str) and len(token) > 0 # limit buy 1 share at a low price (sandbox, no real money). Reuses the cached token. ack = b.submit_order( OrderRequest( slot_id="t", side="buy", symbol="005930", qty=1, limit_price=10000, order_type="00" ) ) assert ack.raw, "expected a KIS response payload" if ack.accepted: assert ack.broker_order_id else: assert ack.reason # rejection carries a reason (e.g., 장 미개장/잔고부족) def test_get_balance_rejects_failure_instead_of_false_empty(monkeypatch): """BAT-057: a rejected KIS response is not a successful empty balance.""" b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) class Response: def raise_for_status(self): pass def json(self): return {"rt_cd": "1", "output1": []} monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response()) with pytest.raises(BrokerBalanceError, match="broker_rejected"): b.get_balance() def test_get_balance_accepts_validated_empty_balance(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) class Response: def raise_for_status(self): pass def json(self): return {"rt_cd": "0", "output1": []} monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response()) assert b.get_balance() == [] def test_get_balance_sanitizes_token_issuance_failure(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr( b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret")) ) with pytest.raises(BrokerBalanceError) as raised: b.get_balance() assert raised.value.code == "token_issuance_failed" assert "secret" not in str(raised.value) def test_get_available_cash_uses_cash_only_amount(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) monkeypatch.setattr(b, "get_current_price", lambda _symbol: None) class Response: def raise_for_status(self): pass def json(self): return { "rt_cd": "0", "output": {"nrcvb_buy_amt": "695591", "max_buy_amt": "999999"}, } monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response()) assert b.get_available_cash() == 695_591 def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) monkeypatch.setattr( "trex_core.adapters.broker.kis.httpx.post", lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")), ) ack = b.submit_order( OrderRequest( slot_id="slot-1", side="buy", symbol="005930", qty=4, limit_price=232_000, ) ) assert ack.accepted is True assert ack.broker_order_id.startswith("submission-unknown:") assert ack.reason == "submit_outcome_unknown:ReadTimeout" cancel = b.cancel_order( ack.broker_order_id, side="buy", symbol="005930", qty=4, ) assert cancel.state is CancelState.UNKNOWN def test_submit_gateway_5xx_blocks_automatic_duplicate(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) class Response: status_code = 502 def json(self): return {"error": "upstream_unavailable"} monkeypatch.setattr( "trex_core.adapters.broker.kis.httpx.post", lambda *a, **kw: Response(), ) ack = b.submit_order( OrderRequest( slot_id="slot-2", side="buy", symbol="005930", qty=4, limit_price=231_500, ) ) assert ack.accepted is True assert ack.broker_order_id.startswith("submission-unknown:") assert ack.reason == "submit_outcome_unknown:http_502" def test_submit_unknown_is_resolved_by_unique_daily_order(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) monkeypatch.setattr( "trex_core.adapters.broker.kis.httpx.post", lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")), ) ack = b.submit_order( OrderRequest( slot_id="slot-resolve", side="buy", symbol="005930", qty=4, limit_price=232_000, ) ) from trex_core.adapters.broker.kis import _decode_submission_unknown submitted = _decode_submission_unknown(ack.broker_order_id)["submitted_at"] from datetime import datetime at = datetime.fromisoformat(submitted) row = { "pdno": "005930", "sll_buy_dvsn_cd": "02", "ord_qty": "4", "ord_unpr": "232000", "ord_dt": at.strftime("%Y%m%d"), "ord_tmd": at.strftime("%H%M%S"), "ord_gno_brno": "91258", "odno": "0012345678", "tot_ccld_qty": "0", "cncl_yn": "N", } class Response: status_code = 200 def json(self): return {"rt_cd": "0", "output1": [row]} monkeypatch.setattr( "trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response(), ) status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") assert status.state == "accepted" assert status.raw["resolved_broker_order_id"] == "91258:0012345678" def test_submit_unknown_confirmed_absent_becomes_retryable_reject(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) monkeypatch.setattr( "trex_core.adapters.broker.kis.httpx.post", lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")), ) ack = b.submit_order( OrderRequest( slot_id="slot-absent", side="buy", symbol="005930", qty=4, limit_price=232_000, ) ) class Response: status_code = 200 def json(self): return {"rt_cd": "0", "output1": []} monkeypatch.setattr( "trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response(), ) monkeypatch.setattr( "trex_core.adapters.broker.kis._SUBMISSION_LOOKUP_GRACE_SECONDS", -1.0, ) status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") assert status.state == "rejected" assert status.raw == {"reason": "submission_confirmed_absent"} def test_fill_poll_transport_failure_uses_backoff(monkeypatch): b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) monkeypatch.setattr(b, "_headers", lambda _tr: {}) clock = [100.0] monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: clock[0]) calls = [] def fail(*args, **kwargs): calls.append((args, kwargs)) raise httpx.ConnectTimeout("timed out") monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail) first = b.poll_fill("91258:1234", side="buy", symbol="005930") immediate = b.poll_fill("91258:1234", side="buy", symbol="005930") assert first.state == "unknown" assert immediate.state == "accepted" assert immediate.raw == {"reason": "fill_poll_backoff"} assert len(calls) == 1 clock[0] += 31 again = b.poll_fill("91258:1234", side="buy", symbol="005930") assert again.state == "unknown" assert len(calls) == 2