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
@@ -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
);