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
+164
View File
@@ -0,0 +1,164 @@
"""Bounded account-read recovery (dcy-041 / GitHub #45)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from trex_core.application.account_snapshot import (
AccountSnapshot,
AccountSnapshotService,
AccountSnapshotUnavailable,
InMemoryAccountSnapshotStore,
)
from trex_core.application.broker_port import BalanceItem, BrokerBalanceError
class FakeAccountBroker:
def __init__(self) -> None:
self.account_hash = "a" * 64
self.balance = [BalanceItem("005930", 2)]
self.cash = 500_000
self.balance_error: str | None = None
self.cash_error: str | None = None
def account_scope_hash(self) -> str:
return self.account_hash
def get_balance(self) -> list[BalanceItem]:
if self.balance_error is not None:
raise BrokerBalanceError(self.balance_error)
return list(self.balance)
def get_available_cash(self, *, price_check_code: str = "005930") -> int:
if self.cash_error is not None:
raise BrokerBalanceError(self.cash_error)
return self.cash
def _wire():
now = [datetime(2026, 7, 29, tzinfo=timezone.utc)]
broker = FakeAccountBroker()
store = InMemoryAccountSnapshotStore()
service = AccountSnapshotService(
broker=broker,
store=store,
buy_max_age_sec=600,
sell_max_age_sec=3600,
clock=lambda: now[0],
rng=lambda: 0.5,
)
return broker, store, service, now
def test_refresh_persists_independent_holdings_and_cash_capture_times():
broker, store, service, now = _wire()
assert service.refresh() is True
first = store.load(broker.account_hash)
assert first is not None
now[0] += timedelta(seconds=120)
broker.balance = [BalanceItem("005930", 3)]
broker.cash_error = "transport_failed"
assert service.refresh() is False
second = store.load(broker.account_hash)
assert second is not None
assert second.holdings == (BalanceItem("005930", 3),)
assert second.holdings_captured_at == now[0]
assert second.available_cash == first.available_cash
assert second.cash_captured_at == first.cash_captured_at
def test_sell_falls_back_only_for_transport_failure_and_within_sell_ttl():
broker, _, service, now = _wire()
assert service.refresh()
broker.balance_error = "transport_failed"
now[0] += timedelta(seconds=601)
assert service.holdings_for_sell() == [BalanceItem("005930", 2)]
assert service.buy_guard(qty=1, limit_price=100_000) == (
"account_snapshot_unavailable"
)
now[0] += timedelta(seconds=3000)
with pytest.raises(AccountSnapshotUnavailable) as raised:
service.holdings_for_sell()
assert raised.value.code == "expired"
@pytest.mark.parametrize(
"error_code",
["authentication_failed", "authorization_failed", "malformed_response",
"broker_rejected", "ownership_mismatch"],
)
def test_sell_never_falls_back_for_non_transport_failures(error_code):
broker, _, service, _ = _wire()
assert service.refresh()
broker.balance_error = error_code
with pytest.raises(AccountSnapshotUnavailable) as raised:
service.holdings_for_sell()
assert raised.value.code == "live_read_failed"
def test_buy_guard_accounts_for_existing_pending_cash_and_never_renews_age():
broker, store, service, now = _wire()
assert service.refresh()
captured = store.load(broker.account_hash).cash_captured_at
assert service.buy_guard(
qty=2, limit_price=100_000, reserved_cash=250_000
) is None
assert service.buy_guard(
qty=3, limit_price=100_000, reserved_cash=250_000
) == "insufficient_snapshot_cash"
assert store.load(broker.account_hash).cash_captured_at == captured
now[0] += timedelta(seconds=601)
assert service.buy_guard(qty=1, limit_price=1) == (
"account_snapshot_unavailable"
)
def test_current_account_hash_is_required_on_every_consume():
broker, _, service, _ = _wire()
assert service.refresh()
broker.account_hash = "b" * 64
assert service.buy_guard(qty=1, limit_price=1) == (
"account_snapshot_unavailable"
)
broker.balance_error = "transport_failed"
with pytest.raises(AccountSnapshotUnavailable) as raised:
service.holdings_for_sell()
assert raised.value.code == "account_mismatch"
def test_restart_transport_attempt_can_use_existing_fresh_cash_snapshot():
broker, store, service, now = _wire()
store.save(
AccountSnapshot(
account_hash=broker.account_hash,
holdings=(BalanceItem("005930", 2),),
holdings_captured_at=now[0],
available_cash=500_000,
cash_captured_at=now[0],
)
)
broker.balance_error = "transport_failed"
broker.cash_error = "transport_failed"
assert service.refresh() is False
assert service.buy_guard(qty=2, limit_price=100_000) is None
def test_invalid_or_missing_buy_notional_fails_closed():
_, _, service, _ = _wire()
assert service.refresh()
assert service.buy_guard(qty=None, limit_price=1) == (
"account_snapshot_price_required"
)
assert service.buy_guard(qty=1, limit_price=None) == (
"account_snapshot_price_required"
)
@@ -99,3 +99,52 @@ def test_no_cap_admits_without_capacity_check():
d = intents.submit_buy(BuyIntent(intent_id=f"b{i}", portfolio_id=pid))
assert d.status == "admitted"
assert repo.count_active(INST) == 3
def test_buy_guard_rejection_is_fail_closed_and_sell_path_is_unaffected():
repo = InMemorySlotRepository()
svc = SlotService(repo, instance_key=INST)
repo.reconcile_universe(INST, {"pf-a": "SYM"})
intents = IntentService(
svc,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda pid: _SYMS.get(pid),
instance_key=INST,
buy_guard_fn=lambda _intent: "insufficient_snapshot_cash",
)
decision = intents.submit_buy(
BuyIntent(
intent_id="guarded",
portfolio_id="pf-a",
qty=1,
entry_price=1000,
)
)
assert decision.status == "rejected"
assert decision.reason == "insufficient_snapshot_cash"
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE
def test_buy_guard_exception_rejects_without_slot_transition():
repo = InMemorySlotRepository()
svc = SlotService(repo, instance_key=INST)
repo.reconcile_universe(INST, {"pf-a": "SYM"})
def broken_guard(_intent):
raise RuntimeError("unavailable")
intents = IntentService(
svc,
repo,
InMemoryIntentStore(),
resolve_symbol=lambda pid: _SYMS.get(pid),
instance_key=INST,
buy_guard_fn=broken_guard,
)
decision = intents.submit_buy(
BuyIntent(intent_id="broken", portfolio_id="pf-a", qty=1, entry_price=1000)
)
assert decision.reason == "account_snapshot_unavailable"
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE
+32
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
@@ -182,6 +183,37 @@ def test_full_sell_qty_is_broker_balance():
assert repo.fetch_slot(sid).status is SlotState.INACTIVE # full exit
def test_pending_sell_uses_injected_bounded_balance_reader():
class LiveReadFailPaper(PaperBroker):
fail_live_balance = False
def get_balance(self):
if self.fail_live_balance:
raise AssertionError("raw balance reader must not be called")
return super().get_balance()
broker = LiveReadFailPaper(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents, qty=10).slot_id
execution.run_once()
svc.sell(sid, actor="t", reason="full")
broker.fail_live_balance = True
broker._fill_after = 5
snapshot_execution = ExecutionService(
svc,
repo,
broker,
instance_key=INST,
balance_reader=lambda: [BalanceItem("005931", 10)],
)
snapshot_execution.run_once()
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
assert len(sell_orders) == 1 and sell_orders[0].qty == 10
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
def test_partial_sell_qty_is_min_intent_broker():
"""dcy-021/REQ-062 (D4): partial sell dispatches min(intent qty, broker);
remainder stays holding (broker-authoritative qty), cost basis preserved."""
@@ -3,8 +3,6 @@ from __future__ import annotations
import logging
import pytest
from trex_core.application.broker_port import BalanceItem
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.messaging import RpcRequest
@@ -20,7 +18,6 @@ from trex_core.application.reconciliation import (
reconcile_core_holdings,
reconcile_positions,
)
from trex_core.application.ports import IntentStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@@ -54,7 +51,8 @@ class InMemoryIntentStore:
return None
class R:
def __init__(self, d): self.__dict__.update(d)
r = R(rec); r.intent_id = intent_id
r = R(rec)
r.intent_id = intent_id
return r
def finalize(self, intent_id, status, slot_id, reason):
self._db[intent_id] = {"status": status, "slot_id": slot_id, "reason": reason}
@@ -211,7 +209,6 @@ def test_reconcile_positions_c_case_matching_no_warn():
# REQ-103 AC-5: C case with warn_store=None => backward compat
def test_reconcile_positions_no_warn_store_backward_compat():
repo = InMemorySlotRepository()
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
@@ -458,7 +455,7 @@ def test_reconcile_core_holdings_b_case_logging(caplog):
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
_make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
reconcile_core_holdings(
@@ -527,7 +524,7 @@ def test_short_term_instance_normal_no_warn():
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
@@ -5,8 +5,6 @@ PostgreSQL adapter is tested by DB-skipped integration tests.
"""
from __future__ import annotations
import pytest
from trex_core.application.portfolio import InMemoryReconcileWarningStore
+1 -5
View File
@@ -2,16 +2,13 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, call
import pytest
from unittest.mock import MagicMock
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.portfolio import (
BrokerCredentialPayload,
PortfolioHubUnavailableError,
PortfolioItem,
SnapshotStore,
TradingContext,
@@ -266,7 +263,6 @@ def test_entry_allowed_fixed_for_day_no_refetch(monkeypatch):
def test_successful_credential_cached_for_day():
"""REQ-115 AC-3: 성공 후 그날 내 재fetch 없음 — 캐시 유지."""
fetch_count = {"n": 0}
original_fetch = StubPortfolioRead.fetch_broker_credential
class CountingStub(StubPortfolioRead):
def fetch_broker_credential(self):
-2
View File
@@ -14,8 +14,6 @@ from __future__ import annotations
from uuid import uuid4
import pytest
from trex_core.application.broker_port import BalanceItem
from trex_core.application.portfolio import (
InMemoryReconcileWarningStore,