feat: add bounded KIS account read recovery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user