feat: add bounded KIS account read recovery
This commit is contained in:
@@ -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