feat: add bounded KIS account read recovery
This commit is contained in:
@@ -112,6 +112,28 @@ class SnapshotScheduleConfig:
|
||||
tick_sec: float = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccountSnapshotConfig:
|
||||
"""Opt-in bounded KIS account-read recovery (dcy-041 / GitHub #45)."""
|
||||
|
||||
enabled: bool = False
|
||||
buy_max_age_sec: int = 600
|
||||
sell_max_age_sec: int = 3600
|
||||
refresh_interval_sec: float = 300.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.buy_max_age_sec < 0:
|
||||
raise ValueError("account snapshot buy_max_age_sec must be >= 0")
|
||||
if self.sell_max_age_sec < self.buy_max_age_sec:
|
||||
raise ValueError(
|
||||
"account snapshot sell_max_age_sec must be >= buy_max_age_sec"
|
||||
)
|
||||
if self.sell_max_age_sec > 21_600:
|
||||
raise ValueError("account snapshot sell_max_age_sec exceeds six hours")
|
||||
if self.refresh_interval_sec <= 0:
|
||||
raise ValueError("account snapshot refresh_interval_sec must be > 0")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafetySellConfig:
|
||||
"""Safety-sell feature tuning (REQ-041 / BAT-010).
|
||||
@@ -227,6 +249,9 @@ class DaemonConfig:
|
||||
rabbitmq: Optional[RabbitMQConfig] = None
|
||||
# REQ-030 (BAT-008): daily snapshot refresh.
|
||||
snapshot_schedule: SnapshotScheduleConfig = SnapshotScheduleConfig()
|
||||
# dcy-041 / GitHub #45: opt-in account read snapshot. Disabled keeps the
|
||||
# previous live-only balance path byte-for-byte compatible.
|
||||
account_snapshot: AccountSnapshotConfig = AccountSnapshotConfig()
|
||||
# REQ-041 (BAT-010): safety-sell feature config.
|
||||
safety_sell: SafetySellConfig = SafetySellConfig()
|
||||
# dcy-013 / REQ-014 (BAT-017): Alert-only pending-order stale watchdog
|
||||
@@ -301,6 +326,20 @@ class DaemonConfig:
|
||||
kst_time=os.environ.get("TREX_SNAPSHOT_SCHEDULE_KST", "07:30").strip() or "07:30",
|
||||
tick_sec=float(os.environ.get("TREX_SNAPSHOT_TICK_SEC", "60")),
|
||||
)
|
||||
account_snapshot = AccountSnapshotConfig(
|
||||
enabled=os.environ.get(
|
||||
"TREX_ACCOUNT_SNAPSHOT_ENABLED", ""
|
||||
).lower() in ("1", "true", "yes"),
|
||||
buy_max_age_sec=int(
|
||||
os.environ.get("TREX_ACCOUNT_SNAPSHOT_BUY_MAX_AGE_SEC", "600")
|
||||
),
|
||||
sell_max_age_sec=int(
|
||||
os.environ.get("TREX_ACCOUNT_SNAPSHOT_SELL_MAX_AGE_SEC", "3600")
|
||||
),
|
||||
refresh_interval_sec=float(
|
||||
os.environ.get("TREX_ACCOUNT_SNAPSHOT_REFRESH_INTERVAL_SEC", "300")
|
||||
),
|
||||
)
|
||||
_mc_time = os.environ.get("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "").strip() or None
|
||||
safety_sell = SafetySellConfig(
|
||||
watchdog_timeout_sec=int(os.environ.get("TREX_WATCHDOG_TIMEOUT", "120")),
|
||||
@@ -329,6 +368,7 @@ class DaemonConfig:
|
||||
ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")),
|
||||
rabbitmq=rabbitmq,
|
||||
snapshot_schedule=snapshot_schedule,
|
||||
account_snapshot=account_snapshot,
|
||||
safety_sell=safety_sell,
|
||||
pending_stale_threshold_sec=int(
|
||||
os.environ.get("TREX_PENDING_STALE_THRESHOLD", "0")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user