From b72bf9f39418487e7029d1d9fc757bd9b4995e79 Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 27 Jul 2026 11:39:07 +0900 Subject: [PATCH] feat: isolate strategy-owned KIS balances --- docs/account-ownership-adapter.md | 14 ++ tests/adapters/broker/test_kis.py | 17 +++ tests/adapters/broker/test_ownership.py | 90 +++++++++++++ trex_core/adapters/broker/kis.py | 44 +++++++ trex_core/adapters/broker/ownership.py | 162 ++++++++++++++++++++++++ trex_core/application/broker_port.py | 1 + trex_core/runtime/broker_provider.py | 19 ++- 7 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 docs/account-ownership-adapter.md create mode 100644 tests/adapters/broker/test_ownership.py create mode 100644 trex_core/adapters/broker/ownership.py diff --git a/docs/account-ownership-adapter.md b/docs/account-ownership-adapter.md new file mode 100644 index 0000000..bd93875 --- /dev/null +++ b/docs/account-ownership-adapter.md @@ -0,0 +1,14 @@ +# KIS 수동·전략 보유수량 분리 어댑터 + +`TREX_KIS_STRATEGY_OWNERSHIP_PATH`가 설정된 KIS 인스턴스는 증권사 전체 잔고 대신 +해당 TREX Core가 체결한 전략수량만 reconciliation에 노출한다. + +- 수동 보유분은 자동매도 수량에 포함하지 않는다. +- 확정 체결은 `execution_id` 기준으로 한 번만 원자 JSON 원장에 반영한다. +- 전략수량이 증권사 전체수량보다 커지면 `ownership_mismatch`로 fail closed 한다. +- 환경변수가 없으면 기존 KIS 잔고 동작은 그대로 유지한다. +- 주문가능 현금은 `KISBroker.get_available_cash()`의 미수 없는 매수금액으로 조회할 + 수 있으며, Core 도메인이 아니라 계정별 외부 동기화 어댑터가 사용한다. + +이 기능은 계정별 영속 경로를 반드시 분리해야 한다. 서로 다른 계정이나 전략 인스턴스가 +같은 소유수량 원장을 공유하면 안 된다. diff --git a/tests/adapters/broker/test_kis.py b/tests/adapters/broker/test_kis.py index 1e1ea93..1862113 100644 --- a/tests/adapters/broker/test_kis.py +++ b/tests/adapters/broker/test_kis.py @@ -110,3 +110,20 @@ def test_get_balance_sanitizes_token_issuance_failure(monkeypatch): 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 diff --git a/tests/adapters/broker/test_ownership.py b/tests/adapters/broker/test_ownership.py new file mode 100644 index 0000000..7042bc7 --- /dev/null +++ b/tests/adapters/broker/test_ownership.py @@ -0,0 +1,90 @@ +from decimal import Decimal + +import pytest + +from trex_core.adapters.broker.ownership import ( + OwnershipAwareBroker, + StrategyOwnershipStore, +) +from trex_core.application.broker_port import ( + BalanceItem, + BrokerBalanceError, + FillStatus, + OrderAck, +) + + +class FakeBroker: + def __init__(self, balance, fill=None): + self.balance = balance + self.fill = fill or FillStatus(state="accepted") + + def name(self): + return "fake" + + def submit_order(self, req): + return OrderAck(accepted=True, broker_order_id="1") + + def poll_fill(self, broker_order_id, *, side, symbol): + return self.fill + + def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"): + return True + + def get_balance(self): + return self.balance + + def get_current_price(self, symbol, market="J"): + return Decimal("1") + + +def test_manual_balance_is_hidden_until_trex_fill(tmp_path): + store = StrategyOwnershipStore(tmp_path / "ownership.json") + broker = OwnershipAwareBroker( + FakeBroker([BalanceItem("005930", 19), BalanceItem("010120", 5)]), + store, + ) + assert broker.get_balance() == [] + + +def test_confirmed_fill_is_persisted_once_and_manual_remainder_stays_hidden(tmp_path): + store = StrategyOwnershipStore(tmp_path / "ownership.json") + fill = FillStatus( + state="filled", + filled_qty=2, + execution_id="kis:1:2", + ) + delegate = FakeBroker([BalanceItem("005930", 21)], fill) + broker = OwnershipAwareBroker(delegate, store) + + broker.poll_fill("1", side="buy", symbol="005930") + broker.poll_fill("1", side="buy", symbol="005930") + + assert broker.get_balance() == [BalanceItem("005930", 2)] + assert StrategyOwnershipStore(store.path).snapshot()[0] == {"005930": 2} + + +def test_strategy_quantity_above_raw_broker_total_fails_closed(tmp_path): + store = StrategyOwnershipStore(tmp_path / "ownership.json") + store.apply_fill( + execution_id="buy-1", + symbol="005930", + side="buy", + quantity=4, + ) + broker = OwnershipAwareBroker(FakeBroker([BalanceItem("005930", 3)]), store) + + with pytest.raises(BrokerBalanceError) as exc: + broker.get_balance() + assert exc.value.code == "ownership_mismatch" + + +def test_sell_cannot_reduce_below_strategy_owned_zero(tmp_path): + store = StrategyOwnershipStore(tmp_path / "ownership.json") + with pytest.raises(BrokerBalanceError): + store.apply_fill( + execution_id="sell-1", + symbol="005930", + side="sell", + quantity=1, + ) diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index 684b528..bb614ce 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -138,6 +138,7 @@ class KISBroker: "appkey": self._app_key, "appsecret": self._app_secret, "tr_id": tr_id, + "custtype": "P", "content-type": "application/json; charset=utf-8", } @@ -343,6 +344,49 @@ class KISBroker: out.append(BalanceItem(symbol=symbol, hldg_qty=qty)) return out + def get_available_cash(self, *, price_check_code: str = "005930") -> int: + """Return KIS cash-only buy capacity (`nrcvb_buy_amt`). + + This is intentionally separate from ``BrokerPort``. An account + compatibility service may use it to refresh per-strategy portfolio + budgets without teaching the domain/Core about mixed manual holdings. + """ + cano, product = self._account() + params = { + "CANO": cano, + "ACNT_PRDT_CD": product, + "PDNO": str(price_check_code).zfill(6), + # KIS recommends market-order mode when checking cash-only buying + # capacity so the symbol's margin ratio is reflected. + "ORD_UNPR": "0", + "ORD_DVSN": "01", + "CMA_EVLU_AMT_ICLD_YN": "Y", + "OVRS_ICLD_YN": "N", + } + try: + headers = self._headers("VTTC8908R" if self._mock else "TTTC8908R") + response = httpx.get( + f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-psbl-order", + headers=headers, + params=params, + timeout=self._timeout, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, KeyError, TypeError, ValueError): + raise BrokerBalanceError("transport_failed") from None + if not isinstance(payload, dict): + raise BrokerBalanceError("malformed_response") + if str(payload.get("rt_cd")) != "0": + raise BrokerBalanceError("broker_rejected") + output = payload.get("output") + if not isinstance(output, dict): + raise BrokerBalanceError("malformed_response") + amount = _safe_int(output.get("nrcvb_buy_amt")) + if amount is None or amount < 0: + raise BrokerBalanceError("malformed_response") + return amount + def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]: """Current traded price `stck_prpr` via KIS inquire-price (REQ-038, REQ-118). diff --git a/trex_core/adapters/broker/ownership.py b/trex_core/adapters/broker/ownership.py new file mode 100644 index 0000000..cd57e28 --- /dev/null +++ b/trex_core/adapters/broker/ownership.py @@ -0,0 +1,162 @@ +"""Optional strategy-only balance projection for mixed manual holdings. + +The KIS account balance contains both operator-owned shares and shares bought +through this TREX Core instance. When a per-account ownership path is +configured, reconciliation must see only the latter. Confirmed TREX fills are +recorded idempotently in an atomic JSON ledger; raw broker totals remain the +upper safety bound. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from threading import Lock + +from trex_core.application.broker_port import ( + BalanceItem, + BrokerBalanceError, + FillStatus, + OrderAck, + OrderRequest, +) + + +class StrategyOwnershipStore: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self._lock = Lock() + + def snapshot(self) -> tuple[dict[str, int], set[str]]: + with self._lock: + return self._read() + + def apply_fill( + self, + *, + execution_id: str, + symbol: str, + side: str, + quantity: int, + ) -> bool: + execution_id = str(execution_id or "").strip() + symbol = str(symbol or "").strip().zfill(6) + side = str(side or "").strip().lower() + quantity = int(quantity) + if not execution_id or len(symbol) != 6 or not symbol.isdigit(): + raise ValueError("valid execution_id and symbol are required") + if side not in {"buy", "sell"} or quantity <= 0: + raise ValueError("fill side/quantity is invalid") + with self._lock: + quantities, applied = self._read() + if execution_id in applied: + return False + current = quantities.get(symbol, 0) + updated = current + quantity if side == "buy" else current - quantity + if updated < 0: + raise BrokerBalanceError("ownership_mismatch") + quantities[symbol] = updated + applied.add(execution_id) + self._write(quantities, applied) + return True + + def project(self, broker_items: list[BalanceItem]) -> list[BalanceItem]: + raw = {str(item.symbol).zfill(6): int(item.hldg_qty) for item in broker_items} + quantities, _ = self.snapshot() + for symbol, owned in quantities.items(): + if owned > raw.get(symbol, 0): + raise BrokerBalanceError("ownership_mismatch") + return [ + BalanceItem(symbol=symbol, hldg_qty=quantity) + for symbol, quantity in sorted(quantities.items()) + if quantity > 0 + ] + + def _read(self) -> tuple[dict[str, int], set[str]]: + if not self.path.exists(): + return {}, set() + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + if payload.get("schema_version") != 1: + raise ValueError("unsupported schema") + quantities = { + str(symbol).zfill(6): int(quantity) + for symbol, quantity in payload["strategy_quantities"].items() + } + applied = {str(value) for value in payload["applied_execution_ids"]} + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + raise BrokerBalanceError("ownership_mismatch") from None + if any(quantity < 0 for quantity in quantities.values()): + raise BrokerBalanceError("ownership_mismatch") + return quantities, applied + + def _write(self, quantities: dict[str, int], applied: set[str]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_name(f".{self.path.name}.tmp") + temporary.write_text( + json.dumps( + { + "schema_version": 1, + "strategy_quantities": dict(sorted(quantities.items())), + "applied_execution_ids": sorted(applied), + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + os.replace(temporary, self.path) + + +class OwnershipAwareBroker: + """Broker decorator that exposes only quantities filled by this TREX instance.""" + + def __init__(self, delegate, store: StrategyOwnershipStore) -> None: + self._delegate = delegate + self._store = store + + def name(self) -> str: + return self._delegate.name() + + def reinitialize(self, credential) -> None: + self._delegate.reinitialize(credential) + + def submit_order(self, req: OrderRequest) -> OrderAck: + return self._delegate.submit_order(req) + + def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus: + result = self._delegate.poll_fill(broker_order_id, side=side, symbol=symbol) + if result.execution_id and result.filled_qty > 0 and ( + result.is_filled or result.is_partial_cancel + ): + self._store.apply_fill( + execution_id=result.execution_id, + symbol=symbol, + side=side, + quantity=result.filled_qty, + ) + return result + + def cancel_order( + self, + broker_order_id: str, + *, + side: str, + symbol: str, + qty: int, + venue: str = "KRX", + ) -> bool: + return self._delegate.cancel_order( + broker_order_id, + side=side, + symbol=symbol, + qty=qty, + venue=venue, + ) + + def get_balance(self) -> list[BalanceItem]: + return self._store.project(self._delegate.get_balance()) + + def get_current_price(self, symbol: str, market: str = "J"): + return self._delegate.get_current_price(symbol, market) diff --git a/trex_core/application/broker_port.py b/trex_core/application/broker_port.py index 5437fcb..7da3c1f 100644 --- a/trex_core/application/broker_port.py +++ b/trex_core/application/broker_port.py @@ -19,6 +19,7 @@ class BrokerBalanceError(RuntimeError): _CODES = frozenset({ "token_issuance_failed", "authentication_failed", "authorization_failed", "transport_failed", "malformed_response", "broker_rejected", + "ownership_mismatch", }) def __init__(self, code: str) -> None: diff --git a/trex_core/runtime/broker_provider.py b/trex_core/runtime/broker_provider.py index 024f714..51a8ccc 100644 --- a/trex_core/runtime/broker_provider.py +++ b/trex_core/runtime/broker_provider.py @@ -56,8 +56,23 @@ def _make_env_broker(config: DaemonConfig) -> BrokerPort: if k.redis_url: from trex_core.adapters.broker.token_cache import RedisTokenCache token_cache = RedisTokenCache(k.redis_url) - return KISBroker(k.account_ref, app_key=k.app_key, app_secret=k.app_secret, base_url=k.base_url, - timeout=k.timeout, allow_live=k.allow_live, token_cache=token_cache) + broker: BrokerPort = KISBroker( + k.account_ref, + app_key=k.app_key, + app_secret=k.app_secret, + base_url=k.base_url, + timeout=k.timeout, + allow_live=k.allow_live, + token_cache=token_cache, + ) + ownership_path = os.environ.get("TREX_KIS_STRATEGY_OWNERSHIP_PATH", "").strip() + if ownership_path: + from trex_core.adapters.broker.ownership import ( + OwnershipAwareBroker, + StrategyOwnershipStore, + ) + broker = OwnershipAwareBroker(broker, StrategyOwnershipStore(ownership_path)) + return broker class ConfigBrokerProvider: