From 0fa8f39be97d6f2615045958fe81755e452d0887 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 00:11:04 +0900 Subject: [PATCH] feat: paginate KIS balance inquiries --- tests/adapters/broker/test_kis.py | 139 ++++++++++++++++++ tests/adapters/broker/test_ownership.py | 16 ++ tests/application/test_holding_divergence.py | 39 ++++- tests/application/test_reconciliation.py | 34 ++++- .../test_kis_submission_recovery_virtual.py | 6 +- trex_core/adapters/broker/kis.py | 100 ++++++++----- 6 files changed, 297 insertions(+), 37 deletions(-) diff --git a/tests/adapters/broker/test_kis.py b/tests/adapters/broker/test_kis.py index 9c711c6..f4d63de 100644 --- a/tests/adapters/broker/test_kis.py +++ b/tests/adapters/broker/test_kis.py @@ -209,6 +209,8 @@ def test_get_balance_rejects_failure_instead_of_false_empty(monkeypatch): monkeypatch.setattr(b, "_headers", lambda _tr: {}) class Response: + headers = {"tr_cont": "E"} + def raise_for_status(self): pass @@ -225,6 +227,8 @@ def test_get_balance_accepts_validated_empty_balance(monkeypatch): monkeypatch.setattr(b, "_headers", lambda _tr: {}) class Response: + headers = {"tr_cont": "E"} + def raise_for_status(self): pass @@ -235,6 +239,141 @@ def test_get_balance_accepts_validated_empty_balance(monkeypatch): 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"), "transport_failed"), + (httpx.HTTPStatusError("bad gateway", request=request, response=response), "transport_failed"), + ]: + 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( diff --git a/tests/adapters/broker/test_ownership.py b/tests/adapters/broker/test_ownership.py index af8e628..9a39301 100644 --- a/tests/adapters/broker/test_ownership.py +++ b/tests/adapters/broker/test_ownership.py @@ -92,6 +92,22 @@ def test_strategy_quantity_above_raw_broker_total_fails_closed(tmp_path): assert exc.value.code == "ownership_mismatch" +def test_projection_includes_strategy_symbol_alongside_first_page_manual_holding(tmp_path): + store = StrategyOwnershipStore(tmp_path / "ownership.json") + store.apply_fill( + execution_id="buy-1", + symbol="000660", + side="buy", + quantity=4, + ) + broker = OwnershipAwareBroker( + FakeBroker([BalanceItem("005930", 19), BalanceItem("000660", 4)]), + store, + ) + + assert broker.get_balance() == [BalanceItem("000660", 4)] + + def test_sell_cannot_reduce_below_strategy_owned_zero(tmp_path): store = StrategyOwnershipStore(tmp_path / "ownership.json") with pytest.raises(BrokerBalanceError): diff --git a/tests/application/test_holding_divergence.py b/tests/application/test_holding_divergence.py index 08cbf3d..f61866a 100644 --- a/tests/application/test_holding_divergence.py +++ b/tests/application/test_holding_divergence.py @@ -3,7 +3,9 @@ from __future__ import annotations from dataclasses import replace -from trex_core.application.broker_port import BalanceItem +import pytest + +from trex_core.application.broker_port import BalanceItem, BrokerBalanceError from trex_core.application.portfolio import InMemoryReconcileWarningStore from trex_core.application.reconciliation import reconcile_core_holdings from trex_core.domain.slot import SlotService, SlotState @@ -24,6 +26,11 @@ class FakeBroker: return list(self._b) +class FailingBroker(FakeBroker): + def get_balance(self): + raise BrokerBalanceError("malformed_response") + + def _hold(repo, svc, pid, symbol, qty): repo.reconcile_universe(INST, {pid: "SYM"}) slot = repo.find_slot_by_portfolio(INST, pid) @@ -77,6 +84,21 @@ def test_ok_when_qty_matches(): assert d[str(slot.slot_id)] == "ok" +def test_later_page_snapshot_symbol_keeps_holding_slot_ok(): + repo, svc = _wire() + slot = _hold(repo, svc, "pf-later", "000660", 4) + + decisions = reconcile_core_holdings( + slot_repo=repo, + slot_service=svc, + broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=4)]), + instance_key=INST, + ) + + assert decisions == {str(slot.slot_id): "ok"} + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + + def test_B_broker_holds_unmanaged_warns(): repo, svc = _wire() broker = FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]) # core has none @@ -149,3 +171,18 @@ def test_other_instance_only_managed_slot_remains_unmanaged_position_without_wri assert repo.update_calls == writes_before assert warn_store.list_warned() == set() assert repo.list_slots(INST) == [] + + +def test_reconcile_core_holdings_propagates_balance_failure_without_slot_write(): + repo, svc = _wire() + slot = _hold(repo, svc, "pf-a", "005930", 10) + + with pytest.raises(BrokerBalanceError, match="malformed_response"): + reconcile_core_holdings( + slot_repo=repo, + slot_service=svc, + broker=FailingBroker([]), + instance_key=INST, + ) + + assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING diff --git a/tests/application/test_reconciliation.py b/tests/application/test_reconciliation.py index 3fe7916..9a7876d 100644 --- a/tests/application/test_reconciliation.py +++ b/tests/application/test_reconciliation.py @@ -1,7 +1,9 @@ """Position reconciliation tests (REQ-031).""" from __future__ import annotations -from trex_core.application.broker_port import BalanceItem +import pytest + +from trex_core.application.broker_port import BalanceItem, BrokerBalanceError from trex_core.application.portfolio import PortfolioItem, SnapshotStore from trex_core.application.reconciliation import reconcile_positions @@ -16,6 +18,11 @@ class FakeBroker: def get_balance(self): return list(self._b) +class FailingBroker(FakeBroker): + def get_balance(self): + raise BrokerBalanceError("malformed_response") + + def _store_with(*items): store = SnapshotStore() store.swap(list(items)) @@ -47,6 +54,20 @@ def test_subsequent_match_auto_clears_warning(): assert store.warnings() == set() +def test_reconcile_positions_accepts_symbol_from_complete_later_page_snapshot(): + store = _store_with( + PortfolioItem(portfolio_id="pf-later", symbol="000660", cash_balance=0, asset_quantity=4), + ) + + decisions = reconcile_positions( + store=store, + broker=FakeBroker([BalanceItem("000660", 4)]), + ) + + assert decisions == {"pf-later": "ok"} + assert store.warnings() == set() + + def test_multiple_portfolios_same_symbol_aggregate(): store = _store_with( PortfolioItem(portfolio_id="pf-A", symbol="005930", @@ -86,3 +107,14 @@ def test_paper_broker_returns_deterministic_balance(): syms = {b.symbol for b in broker.get_balance()} assert "000660" not in syms # net 0 → dropped assert "005930" in syms + + +def test_reconcile_positions_propagates_balance_failure_without_warning_write(): + store = _store_with( + PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10), + ) + + with pytest.raises(BrokerBalanceError, match="malformed_response"): + reconcile_positions(store=store, broker=FailingBroker([])) + + assert store.warnings() == set() diff --git a/tests/integration/test_kis_submission_recovery_virtual.py b/tests/integration/test_kis_submission_recovery_virtual.py index d7912f2..67cb87d 100644 --- a/tests/integration/test_kis_submission_recovery_virtual.py +++ b/tests/integration/test_kis_submission_recovery_virtual.py @@ -96,15 +96,17 @@ def _serve(state: _ExchangeState): def do_GET(self): if self.path.startswith("/uapi/domestic-stock/v1/trading/inquire-balance"): - self._reply(state.balance()) + self._reply(state.balance(), tr_cont="E") else: self._reply(state.daily_orders()) - def _reply(self, payload): + def _reply(self, payload, *, tr_cont=None): raw = json.dumps(payload).encode() try: self.send_response(200) self.send_header("content-type", "application/json") + if tr_cont is not None: + self.send_header("tr_cont", tr_cont) self.send_header("content-length", str(len(raw))) self.end_headers() self.wfile.write(raw) diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index d2a4e15..80054af 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -652,41 +652,75 @@ class KISBroker: # Token issuance/header construction is distinct from a rejected # balance endpoint response and must never look like an empty book. raise BrokerBalanceError("token_issuance_failed") from None - try: - resp = httpx.get( - f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance", - headers=headers, - params=params, - timeout=self._timeout, + + quantities: dict[str, int] = {} + seen_continuations: set[tuple[str, str]] = set() + continuation: Optional[tuple[str, str]] = None + while True: + request_headers = headers + if continuation is not None: + params["CTX_AREA_FK100"], params["CTX_AREA_NK100"] = continuation + request_headers = {**headers, "tr_cont": "N"} + try: + resp = httpx.get( + f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance", + headers=request_headers, + params=params, + timeout=self._timeout, + ) + resp.raise_for_status() + payload = resp.json() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else None + raise BrokerBalanceError(_balance_http_error_code(status)) from None + except httpx.HTTPError: + raise BrokerBalanceError("transport_failed") from None + except (ValueError, TypeError): + raise BrokerBalanceError("malformed_response") from None + + if not isinstance(payload, dict): + raise BrokerBalanceError("malformed_response") + if str(payload.get("rt_cd")) != "0": + raise BrokerBalanceError("broker_rejected") + rows = payload.get("output1") + if not isinstance(rows, list): + raise BrokerBalanceError("malformed_response") + for row in rows: + if not isinstance(row, dict): + raise BrokerBalanceError("malformed_response") + qty = _safe_int(row.get("hldg_qty")) + if qty is None: + raise BrokerBalanceError("malformed_response") + if qty <= 0: + continue + symbol = str(row.get("pdno") or "").strip() + if not symbol: + raise BrokerBalanceError("malformed_response") + quantities[symbol] = quantities.get(symbol, 0) + qty + + response_headers = getattr(resp, "headers", None) + if response_headers is None: + raise BrokerBalanceError("malformed_response") + tr_cont = response_headers.get("tr_cont") + if not isinstance(tr_cont, str): + raise BrokerBalanceError("malformed_response") + tr_cont = tr_cont.strip().upper() + if tr_cont in {"D", "E"}: + return [ + BalanceItem(symbol=symbol, hldg_qty=qty) + for symbol, qty in quantities.items() + ] + if tr_cont not in {"F", "M"}: + raise BrokerBalanceError("malformed_response") + + next_continuation = ( + str(payload.get("ctx_area_fk100") or "").strip(), + str(payload.get("ctx_area_nk100") or "").strip(), ) - resp.raise_for_status() - payload = resp.json() - except httpx.HTTPStatusError as exc: - status = exc.response.status_code if exc.response is not None else None - raise BrokerBalanceError(_balance_http_error_code(status)) from None - except httpx.HTTPError: - raise BrokerBalanceError("transport_failed") from None - except (ValueError, TypeError): - raise BrokerBalanceError("malformed_response") from None - if not isinstance(payload, dict): - raise BrokerBalanceError("malformed_response") - if str(payload.get("rt_cd")) != "0": - raise BrokerBalanceError("broker_rejected") - rows = payload.get("output1") - if not isinstance(rows, list): - raise BrokerBalanceError("malformed_response") - out: list[BalanceItem] = [] - for row in rows: - if not isinstance(row, dict): + if not all(next_continuation) or next_continuation in seen_continuations: raise BrokerBalanceError("malformed_response") - symbol = str(row.get("pdno") or "").strip() - qty = _safe_int(row.get("hldg_qty")) - if qty is None: - raise BrokerBalanceError("malformed_response") - if not symbol or qty <= 0: - continue - out.append(BalanceItem(symbol=symbol, hldg_qty=qty)) - return out + seen_continuations.add(next_continuation) + continuation = next_continuation def get_available_cash(self, *, price_check_code: str = "005930") -> int: """Return KIS cash-only buy capacity (`nrcvb_buy_amt`).