feat: add bounded KIS account read recovery

This commit is contained in:
jordan
2026-07-29 17:40:01 +09:00
parent 3dd8eefc92
commit 0266992238
29 changed files with 970 additions and 26 deletions
+19 -1
View File
@@ -12,6 +12,7 @@ Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash
from __future__ import annotations
import base64
import hashlib
import json
from dataclasses import replace
from datetime import datetime, time, timedelta, timezone
@@ -157,6 +158,11 @@ class KISBroker:
def name(self) -> str:
return "kis-mock" if self._mock else "kis-live"
def account_scope_hash(self) -> str:
"""Stable non-secret identity for account-scoped persisted state."""
cano, product = self._account()
return hashlib.sha256(f"{cano}|{product}".encode()).hexdigest()
# --- credential hot-swap (dcy-036 / REQ-113 AC-2 / REQ-115 AC-1) ----------
def reinitialize(self, credential) -> None:
"""Replace app_key/app_secret/account_ref with fetched day-start credential.
@@ -711,8 +717,20 @@ class KISBroker:
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, KeyError, TypeError, ValueError):
except BrokerBalanceError:
raise
except httpx.HTTPStatusError as exc:
status = exc.response.status_code if exc.response is not None else None
code = (
"authentication_failed"
if status == 401
else ("authorization_failed" if status == 403 else "transport_failed")
)
raise BrokerBalanceError(code) from None
except httpx.HTTPError:
raise BrokerBalanceError("transport_failed") from None
except (KeyError, TypeError, ValueError):
raise BrokerBalanceError("malformed_response") from None
if not isinstance(payload, dict):
raise BrokerBalanceError("malformed_response")
if str(payload.get("rt_cd")) != "0":
+6
View File
@@ -159,5 +159,11 @@ class OwnershipAwareBroker:
def get_balance(self) -> list[BalanceItem]:
return self._store.project(self._delegate.get_balance())
def account_scope_hash(self) -> str:
return self._delegate.account_scope_hash()
def get_available_cash(self, *, price_check_code: str = "005930") -> int:
return self._delegate.get_available_cash(price_check_code=price_check_code)
def get_current_price(self, symbol: str, market: str = "J"):
return self._delegate.get_current_price(symbol, market)
@@ -0,0 +1,104 @@
"""PostgreSQL persistence for bounded broker account snapshots."""
from __future__ import annotations
import json
from contextlib import contextmanager
from datetime import datetime
from typing import Any, Optional
from trex_core.application.account_snapshot import AccountSnapshot
from trex_core.application.broker_port import BalanceItem
def _require_psycopg(module: Any = None) -> Any:
if module is not None:
return module
try:
import psycopg # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover
raise RuntimeError("psycopg is required for account snapshot persistence") from exc
return psycopg
class PostgreSQLAccountSnapshotStore:
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._dsn = dsn
self._psycopg = _require_psycopg(psycopg_module)
def save(self, snapshot: AccountSnapshot) -> None:
holdings = [
{"symbol": item.symbol, "hldg_qty": item.hldg_qty}
for item in snapshot.holdings
]
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO broker_account_snapshot "
"(account_hash, holdings, holdings_captured_at, "
"available_cash, cash_captured_at) "
"VALUES (%s, %s::jsonb, %s, %s, %s) "
"ON CONFLICT (account_hash) DO UPDATE SET "
"holdings=excluded.holdings, "
"holdings_captured_at=excluded.holdings_captured_at, "
"available_cash=excluded.available_cash, "
"cash_captured_at=excluded.cash_captured_at",
(
snapshot.account_hash,
json.dumps(holdings, separators=(",", ":"), sort_keys=True),
snapshot.holdings_captured_at,
snapshot.available_cash,
snapshot.cash_captured_at,
),
)
conn.commit()
def load(self, account_hash: str) -> Optional[AccountSnapshot]:
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT holdings, holdings_captured_at, "
"available_cash, cash_captured_at "
"FROM broker_account_snapshot WHERE account_hash=%s",
(account_hash,),
)
row = cur.fetchone()
if row is None:
return None
raw_holdings = row[0]
if isinstance(raw_holdings, str):
raw_holdings = json.loads(raw_holdings)
holdings = tuple(
BalanceItem(
symbol=str(item["symbol"]),
hldg_qty=int(item["hldg_qty"]),
)
for item in raw_holdings
)
return AccountSnapshot(
account_hash=account_hash,
holdings=holdings,
holdings_captured_at=(
row[1] if isinstance(row[1], datetime)
else (datetime.fromisoformat(row[1]) if row[1] else None)
),
available_cash=int(row[2]) if row[2] is not None else None,
cash_captured_at=(
row[3] if isinstance(row[3], datetime)
else (datetime.fromisoformat(row[3]) if row[3] else None)
),
)
@contextmanager
def refresh_guard(self, account_hash: str):
"""One refresh owner per account across Core processes."""
lock_id = int(account_hash[:15], 16)
with self._psycopg.connect(self._dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT pg_try_advisory_lock(%s)", (lock_id,))
acquired = bool(cur.fetchone()[0])
try:
yield acquired
finally:
if acquired:
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
@@ -0,0 +1,10 @@
-- dcy-041 / GitHub #45: account-scoped last healthy KIS snapshot.
-- account_hash is SHA-256(CANO|ACNT_PRDT_CD); no plaintext account identity,
-- token, key, order response, or broker payload is persisted.
CREATE TABLE IF NOT EXISTS broker_account_snapshot (
account_hash TEXT PRIMARY KEY,
holdings JSONB NOT NULL,
holdings_captured_at TIMESTAMPTZ,
available_cash BIGINT CHECK (available_cash IS NULL OR available_cash >= 0),
cash_captured_at TIMESTAMPTZ
);