"""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 from datetime import UTC, datetime, timedelta 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, "http_5xx"), (504, "timeout"), (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" @pytest.mark.parametrize( ("base_url", "allow_live", "expected_tr_id"), [ (_MOCK_URL, False, "VTTC0081R"), ("https://openapi.koreainvestment.com:9443", True, "TTTC0081R"), ], ) def test_daily_fill_request_uses_current_all_market_contract( base_url, allow_live, expected_tr_id, ): broker = KISBroker( "12345678-01", app_key="k", app_secret="s", base_url=base_url, allow_live=allow_live, ) tr_id, params = broker._daily_fill_request( "20260729", symbol="005930", order_number="1234", ) assert tr_id == expected_tr_id assert params["EXCG_ID_DVSN_CD"] == "ALL" assert params["PDNO"] == "005930" assert params["ODNO"] == "1234" def test_poll_fill_uses_current_all_market_contract(monkeypatch): broker = KISBroker( "12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL, ) seen = {} monkeypatch.setattr( broker, "_headers", lambda tr_id: seen.setdefault("tr_id", tr_id) and {}, ) class Response: status_code = 200 def json(self): return {"rt_cd": "0", "output1": []} def fake_get(*args, **kwargs): seen["params"] = kwargs["params"] return Response() monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get) status = broker.poll_fill( "91258:1234", side="buy", symbol="005930", order_date="20260608" ) assert status.state == "accepted" assert seen["tr_id"] == "VTTC0081R" assert seen["params"]["EXCG_ID_DVSN_CD"] == "ALL" assert seen["params"]["ODNO"] == "1234" 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: headers = {"tr_cont": "E"} 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: headers = {"tr_cont": "E"} 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() == [] class _BalanceResponse: def __init__(self, payload, tr_cont="E"): self.payload = payload self.headers = {} if tr_cont is None else {"tr_cont": tr_cont} def raise_for_status(self): pass def json(self): return self.payload def _balance_broker(monkeypatch, responses): broker = KISBroker("12345678-01", app_key="app-key", app_secret="app-secret", base_url=_MOCK_URL) monkeypatch.setattr(broker, "_headers", lambda _tr: {"authorization": "Bearer token"}) calls = [] def fake_get(*args, **kwargs): calls.append({**kwargs, "headers": dict(kwargs["headers"]), "params": dict(kwargs["params"])}) response = responses.pop(0) if isinstance(response, BaseException): raise response return response monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get) return broker, calls def _balance_payload(rows, *, fk="", nk="", rt_cd="0"): return {"rt_cd": rt_cd, "output1": rows, "ctx_area_fk100": fk, "ctx_area_nk100": nk} def test_get_balance_reads_all_pages_and_aggregates_by_symbol(monkeypatch): broker, calls = _balance_broker(monkeypatch, [ _BalanceResponse(_balance_payload([{"pdno": "005930", "hldg_qty": "2"}], fk="A", nk="1"), "F"), _BalanceResponse(_balance_payload([{"pdno": "000660", "hldg_qty": "4"}], fk="B", nk="2"), "M"), _BalanceResponse(_balance_payload([ {"pdno": "005930", "hldg_qty": "3"}, {"pdno": "035420", "hldg_qty": "1"}, ]), "E"), ]) assert [(item.symbol, item.hldg_qty) for item in broker.get_balance()] == [ ("005930", 5), ("000660", 4), ("035420", 1), ] assert len(calls) == 3 assert "tr_cont" not in calls[0]["headers"] assert calls[0]["params"]["CTX_AREA_FK100"] == "" assert calls[0]["params"]["CTX_AREA_NK100"] == "" assert calls[1]["headers"]["tr_cont"] == "N" assert calls[1]["params"]["CTX_AREA_FK100"] == "A" assert calls[1]["params"]["CTX_AREA_NK100"] == "1" assert calls[2]["headers"]["tr_cont"] == "N" assert calls[2]["params"]["CTX_AREA_FK100"] == "B" assert calls[2]["params"]["CTX_AREA_NK100"] == "2" @pytest.mark.parametrize("terminal", ["D", "E"]) def test_get_balance_stops_at_terminal_header_without_page_size_assumptions(monkeypatch, terminal): broker, calls = _balance_broker(monkeypatch, [ _BalanceResponse(_balance_payload([ {"pdno": "005930", "hldg_qty": "1"}, {"pdno": "000660", "hldg_qty": "0"}, {"pdno": "035420", "hldg_qty": "-1"}, ]), terminal), ]) assert [(item.symbol, item.hldg_qty) for item in broker.get_balance()] == [("005930", 1)] assert len(calls) == 1 @pytest.mark.parametrize( ("responses", "expected_code", "calls_expected"), [ ([_BalanceResponse(_balance_payload([]), None)], "malformed_response", 1), ([_BalanceResponse(_balance_payload([]), "X")], "malformed_response", 1), ([_BalanceResponse(_balance_payload([], fk="", nk=""), "F")], "malformed_response", 1), ([_BalanceResponse(_balance_payload([], fk="A", nk="1"), "F"), _BalanceResponse(_balance_payload([], fk="A", nk="1"), "M")], "malformed_response", 2), ([_BalanceResponse(_balance_payload([], fk="A", nk="1"), "F"), _BalanceResponse(_balance_payload([], fk="B", nk="2"), "M"), _BalanceResponse(_balance_payload([], fk="A", nk="1"), "F")], "malformed_response", 3), ([_BalanceResponse(_balance_payload(["not-an-object"]))], "malformed_response", 1), ([_BalanceResponse(_balance_payload([{"pdno": "005930"}]))], "malformed_response", 1), ([_BalanceResponse(_balance_payload([{"pdno": "005930", "hldg_qty": "bad"}]))], "malformed_response", 1), ([_BalanceResponse(_balance_payload([{"pdno": "", "hldg_qty": "1"}]))], "malformed_response", 1), ([_BalanceResponse(_balance_payload({}, rt_cd="0"))], "malformed_response", 1), ([_BalanceResponse([], "E")], "malformed_response", 1), ([_BalanceResponse(_balance_payload([], rt_cd="1"))], "broker_rejected", 1), ], ) def test_get_balance_fails_closed_for_malformed_or_nonprogressing_pages( monkeypatch, responses, expected_code, calls_expected ): broker, calls = _balance_broker(monkeypatch, responses) with pytest.raises(BrokerBalanceError) as raised: broker.get_balance() assert raised.value.code == expected_code assert len(calls) == calls_expected assert "app-secret" not in str(raised.value) assert "token" not in str(raised.value) def test_get_balance_fails_closed_on_later_transport_and_http_errors(monkeypatch): request = httpx.Request("GET", "https://example.test/balance") response = httpx.Response(503, request=request) for failure, expected_code in [ (httpx.ConnectTimeout("unavailable"), "timeout"), (httpx.HTTPStatusError("bad gateway", request=request, response=response), "http_5xx"), ]: broker, calls = _balance_broker(monkeypatch, [ _BalanceResponse(_balance_payload([{"pdno": "005930", "hldg_qty": "2"}], fk="A", nk="1"), "F"), failure, ]) with pytest.raises(BrokerBalanceError) as raised: broker.get_balance() assert raised.value.code == expected_code assert len(calls) == 2 def test_get_balance_fails_closed_on_json_decode_error(monkeypatch): class InvalidJsonResponse(_BalanceResponse): def json(self): raise ValueError("invalid JSON with app-secret") broker, calls = _balance_broker(monkeypatch, [InvalidJsonResponse(None)]) with pytest.raises(BrokerBalanceError) as raised: broker.get_balance() assert raised.value.code == "malformed_response" assert len(calls) == 1 assert "app-secret" not in str(raised.value) 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 @pytest.mark.parametrize( ("failure", "expected_code"), [ (httpx.ReadTimeout("timed out"), "timeout"), ( httpx.HTTPStatusError( "bad gateway", request=httpx.Request("GET", "https://example.test"), response=httpx.Response(502, request=httpx.Request("GET", "https://example.test")), ), "http_5xx", ), ], ) def test_get_available_cash_classifies_timeout_and_http_5xx(monkeypatch, failure, expected_code): 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.get", lambda *a, **kw: (_ for _ in ()).throw(failure)) with pytest.raises(BrokerBalanceError) as raised: b.get_available_cash() assert raised.value.code == expected_code 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) seen_tr_ids = [] monkeypatch.setattr( b, "_headers", lambda tr_id: seen_tr_ids.append(tr_id) or {}, ) 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]} seen_params = {} def fake_get(*args, **kwargs): seen_params.update(kwargs["params"]) return Response() monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get) 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" assert seen_tr_ids[-1] == "VTTC0081R" assert seen_params["EXCG_ID_DVSN_CD"] == "ALL" assert seen_params["PDNO"] == "005930" 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" assert status.raw["poll_performed"] is True assert status.raw["poll_count"] == 1 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", order_date="20260608" ) immediate = b.poll_fill( "91258:1234", side="buy", symbol="005930", order_date="20260608" ) assert first.state == "unknown" assert first.raw["reason"] == "fill_inquiry_transport" assert "timed out" not in str(first.raw) assert immediate.state == "accepted" assert immediate.raw["reason"] == "fill_poll_backoff" assert immediate.raw["poll_performed"] is False assert immediate.raw["poll_count"] == 1 assert len(calls) == 1 clock[0] += 31 again = b.poll_fill( "91258:1234", side="buy", symbol="005930", order_date="20260608" ) assert again.state == "unknown" assert len(calls) == 2 def test_unresolved_fill_poll_uses_elapsed_bounded_backoff_and_converges(monkeypatch): wall_now = datetime(2026, 9, 10, 12, 0, tzinfo=UTC) b = KISBroker( "12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL, clock=lambda: wall_now, ) monkeypatch.setattr(b, "_headers", lambda _tr: {}) mono = [100.0] monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: mono[0]) calls = [] terminal = [False] class Response: status_code = 200 def json(self): calls.append(1) parent = { "odno": "1234", "orgn_odno": "", "ord_qty": "10", "tot_ccld_qty": "0", "avg_prvs": "0", "cncl_yn": "", "rjct_qty": "0", "rmn_qty": "0", "ord_dt": "20260910", "ord_gno_brno": "91258", "pdno": "005930", } child = { **parent, "odno": "1235", "orgn_odno": "1234", "cnc_cfrm_qty": "10" if terminal[0] else "0", } return {"rt_cd": "0", "output1": [parent, child]} monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response()) submitted_at = wall_now - timedelta(hours=7) first = b.poll_fill( "91258:1234", side="buy", symbol="005930", order_date="20260910", submitted_at=submitted_at, ) assert first.state == "unknown" assert first.raw["poll_performed"] is True assert first.raw["poll_count"] == 1 assert first.raw["backoff_sec"] == 300.0 assert first.raw["stuck_duration_sec"] == 7 * 3600 terminal[0] = True mono[0] += 299 skipped = b.poll_fill( "91258:1234", side="buy", symbol="005930", order_date="20260910", submitted_at=submitted_at, ) assert skipped.state == "accepted" assert skipped.raw["last_result_state"] == "unknown" assert len(calls) == 1 mono[0] += 1 resolved = b.poll_fill( "91258:1234", side="buy", symbol="005930", order_date="20260910", submitted_at=submitted_at, ) assert resolved.state == "cancelled" assert resolved.raw["poll_count"] == 2 assert len(calls) == 2