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
+77
View File
@@ -32,6 +32,9 @@ from trex_core.adapters.messaging import (
topology,
)
from trex_core.adapters.persistence.migrate import apply_migrations
from trex_core.adapters.persistence.account_snapshot import (
PostgreSQLAccountSnapshotStore,
)
from trex_core.adapters.persistence.postgresql import (
PgStrategyStateStore,
PostgreSQLIntentStore,
@@ -46,9 +49,11 @@ from trex_core.adapters.portfolio import (
)
from trex_core.adapters.portfolio.killswitch_store import PostgreSQLKillSwitchStore
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.account_snapshot import AccountSnapshotService
from trex_core.application.broker_port import BrokerPort
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import IntentService
from trex_core.application.intents import BuyIntent
from trex_core.application.ledger_outbox import LedgerWriter
from trex_core.application.ledger_port import LedgerPort
from trex_core.application.messaging import MessagingTransport
@@ -199,6 +204,28 @@ class Daemon:
from trex_core.runtime.broker_provider import build_broker_provider
self.broker_provider = build_broker_provider(config, read_port=self.portfolio_read)
self.broker, self.broker_source = self.broker_provider.get_broker_for_read()
self.account_snapshot_service: AccountSnapshotService | None = None
if config.account_snapshot.enabled:
if config.broker != "kis":
raise RuntimeError(
"account snapshot recovery is supported only with broker=kis"
)
required = (
"account_scope_hash",
"get_balance",
"get_available_cash",
)
if any(not callable(getattr(self.broker, name, None)) for name in required):
raise RuntimeError(
"configured KIS broker lacks account snapshot capabilities"
)
self.account_snapshot_service = AccountSnapshotService(
broker=self.broker, # type: ignore[arg-type]
store=PostgreSQLAccountSnapshotStore(config.dsn),
buy_max_age_sec=config.account_snapshot.buy_max_age_sec,
sell_max_age_sec=config.account_snapshot.sell_max_age_sec,
refresh_interval_sec=config.account_snapshot.refresh_interval_sec,
)
# Event publisher transport — one dedicated instance for the
# execution-loop thread (pika is not thread-safe).
@@ -263,6 +290,11 @@ class Daemon:
default_order_timeout_sec=config.default_order_timeout_sec, # dcy-022
warn_store=self.warn_store, # BAT-050 / REQ-104: reconcile-warning gate
entry_allowed_fn=entry_allowed_fn, # dcy-036 / REQ-114: day-start gate
buy_guard_fn=(
self._account_snapshot_buy_guard
if self.account_snapshot_service is not None
else None
),
)
self.execution = ExecutionService(
self.slot_service, self.slot_repo, self.broker,
@@ -271,6 +303,11 @@ class Daemon:
stale_pending_threshold_sec=config.pending_stale_threshold_sec,
instance_key=config.instance_key,
alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100
balance_reader=(
self.account_snapshot_service.holdings_for_sell
if self.account_snapshot_service is not None
else None
),
)
# BAT-011: safety-sell trigger service (forced_close / SL-TP / market-close).
@@ -293,6 +330,35 @@ class Daemon:
asset = self.snapshot_store.get(portfolio_id)
return asset.item.symbol if asset is not None else None
def _account_snapshot_buy_guard(self, intent: BuyIntent) -> str | None:
"""Bound one new buy against fresh cached cash plus pending reservations."""
service = self.account_snapshot_service
if service is None:
return None
reserved_cash = 0.0
for slot in self.slot_repo.list_slots(
self.config.instance_key, status_filter={"pending_buy"}
):
if (
slot.qty is None
or slot.qty <= 0
or slot.limit_price is None
or slot.limit_price <= 0
):
return "pending_buy_notional_unknown"
price = float(slot.limit_price)
if slot.slippage_pct:
price *= 1.0 + float(slot.slippage_pct) / 100.0
reserved_cash += float(slot.qty) * price
price = intent.entry_price
if price is not None and intent.slippage_pct:
price *= 1.0 + float(intent.slippage_pct) / 100.0
return service.buy_guard(
qty=intent.qty,
limit_price=price,
reserved_cash=reserved_cash,
)
# ------------------------------------------------------------ event hook
def _make_event_emitter(self):
def emit(event_type: str, slot, routing_strategy_id: str | None = None) -> None:
@@ -424,9 +490,18 @@ class Daemon:
except Exception: # noqa: BLE001
_log.exception("snapshot scheduler loop crashed")
def _account_snapshot_loop(self) -> None:
service = self.account_snapshot_service
if service is not None:
service.run_forever(self.stop)
# ------------------------------------------------------------ lifecycle
def setup(self) -> None:
apply_migrations(self.config.dsn)
if self.account_snapshot_service is not None:
# First refresh is synchronous: buy admission never starts from a
# stale row without first attempting the current account.
self.account_snapshot_service.refresh()
# dcy-020 (BAT-030): the daily pool preallocation is gone — permanent
# asset slots are created/retired by the snapshot scheduler's universe
# reconcile (boot fetch runs immediately in its thread).
@@ -453,6 +528,8 @@ class Daemon:
"safety_sell_enabled=false — trex-safety-sell worker not started. "
"Autonomous safety-sell will NOT run."
)
if self.account_snapshot_service is not None:
workers.append(("trex-account-snapshot", self._account_snapshot_loop))
for name, target in workers:
t = threading.Thread(target=target, name=name, daemon=True)
t.start()