feat: add bounded KIS account read recovery
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user