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
+2
View File
@@ -27,6 +27,8 @@ def test_account_split_and_mock_tr_ids():
assert b._tr_submit("buy") == "VTTC0012U"
assert b._tr_submit("sell") == "VTTC0011U"
assert b.name() == "kis-mock"
assert len(b.account_scope_hash()) == 64
assert "12345678" not in b.account_scope_hash()
def test_live_base_url_requires_allow_live():
+36
View File
@@ -1,4 +1,5 @@
from decimal import Decimal
from datetime import datetime, timezone
import pytest
@@ -14,6 +15,10 @@ from trex_core.application.broker_port import (
FillStatus,
OrderAck,
)
from trex_core.application.account_snapshot import (
AccountSnapshotService,
InMemoryAccountSnapshotStore,
)
class FakeBroker:
@@ -36,6 +41,12 @@ class FakeBroker:
def get_balance(self):
return self.balance
def account_scope_hash(self):
return "c" * 64
def get_available_cash(self, *, price_check_code="005930"):
return 1_000_000
def get_current_price(self, symbol, market="J"):
return Decimal("1")
@@ -90,3 +101,28 @@ def test_sell_cannot_reduce_below_strategy_owned_zero(tmp_path):
side="sell",
quantity=1,
)
def test_account_snapshot_keeps_manual_holdings_outside_strategy_scope(tmp_path):
ownership = StrategyOwnershipStore(tmp_path / "ownership.json")
ownership.apply_fill(
execution_id="buy-1",
symbol="005930",
side="buy",
quantity=2,
)
broker = OwnershipAwareBroker(
FakeBroker([BalanceItem("005930", 21), BalanceItem("000660", 1)]),
ownership,
)
snapshots = InMemoryAccountSnapshotStore()
service = AccountSnapshotService(
broker=broker,
store=snapshots,
clock=lambda: datetime(2026, 7, 29, tzinfo=timezone.utc),
)
assert service.refresh()
snapshot = snapshots.load("c" * 64)
assert snapshot is not None
assert snapshot.holdings == (BalanceItem("005930", 2),)
@@ -0,0 +1,37 @@
"""PostgreSQL account snapshot persistence contract."""
from __future__ import annotations
from datetime import datetime, timezone
from trex_core.adapters.persistence.account_snapshot import (
PostgreSQLAccountSnapshotStore,
)
from trex_core.application.account_snapshot import AccountSnapshot
from trex_core.application.broker_port import BalanceItem
def test_round_trip_keeps_independent_capture_times(pg_clean):
store = PostgreSQLAccountSnapshotStore(pg_clean)
holdings_at = datetime(2026, 7, 29, 0, 0, tzinfo=timezone.utc)
cash_at = datetime(2026, 7, 28, 23, 59, tzinfo=timezone.utc)
expected = AccountSnapshot(
account_hash="d" * 64,
holdings=(BalanceItem("005930", 2),),
holdings_captured_at=holdings_at,
available_cash=300_000,
cash_captured_at=cash_at,
)
store.save(expected)
assert store.load(expected.account_hash) == expected
def test_refresh_guard_is_account_scoped_and_released(pg_clean):
store = PostgreSQLAccountSnapshotStore(pg_clean)
with store.refresh_guard("e" * 64) as first:
assert first is True
with store.refresh_guard("e" * 64) as second:
assert second is False
with store.refresh_guard("e" * 64) as after_release:
assert after_release is True
@@ -18,6 +18,7 @@ _EXPECTED_TABLES = {
"intent_log",
"ledger_outbox", # BAT-006 / REQ-019
"snapshot_pause", # BAT-008 / REQ-032
"broker_account_snapshot", # dcy-041 / GitHub #45
}
+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,
+1
View File
@@ -15,6 +15,7 @@ import os
import pytest
_PG_TABLES = (
"broker_account_snapshot",
"killswitch_exclusion",
"snapshot_pause",
"ledger_outbox",
@@ -0,0 +1,55 @@
"""Account snapshot configuration stays opt-in and bounded."""
from __future__ import annotations
import pytest
from trex_core.runtime.config import AccountSnapshotConfig, DaemonConfig
_KEYS = (
"TREX_ACCOUNT_SNAPSHOT_ENABLED",
"TREX_ACCOUNT_SNAPSHOT_BUY_MAX_AGE_SEC",
"TREX_ACCOUNT_SNAPSHOT_SELL_MAX_AGE_SEC",
"TREX_ACCOUNT_SNAPSHOT_REFRESH_INTERVAL_SEC",
)
@pytest.fixture(autouse=True)
def _clean(monkeypatch):
for key in _KEYS:
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "dbname=x")
def test_defaults_are_disabled_and_bounded():
config = DaemonConfig.from_env().account_snapshot
assert config == AccountSnapshotConfig()
assert config.enabled is False
def test_env_enables_and_tunes_account_snapshot():
import os
os.environ["TREX_ACCOUNT_SNAPSHOT_ENABLED"] = "true"
os.environ["TREX_ACCOUNT_SNAPSHOT_BUY_MAX_AGE_SEC"] = "300"
os.environ["TREX_ACCOUNT_SNAPSHOT_SELL_MAX_AGE_SEC"] = "1800"
os.environ["TREX_ACCOUNT_SNAPSHOT_REFRESH_INTERVAL_SEC"] = "240"
config = DaemonConfig.from_env().account_snapshot
assert config.enabled is True
assert config.buy_max_age_sec == 300
assert config.sell_max_age_sec == 1800
assert config.refresh_interval_sec == 240
@pytest.mark.parametrize(
"kwargs",
[
{"buy_max_age_sec": -1},
{"buy_max_age_sec": 601, "sell_max_age_sec": 600},
{"sell_max_age_sec": 21_601},
{"refresh_interval_sec": 0},
],
)
def test_invalid_age_or_interval_policy_is_rejected(kwargs):
with pytest.raises(ValueError):
AccountSnapshotConfig(**kwargs)
+1 -1
View File
@@ -322,7 +322,7 @@ class TestReportWacPath:
return_value=mock_adapter,
),
):
rc = cli.main(["report", "--json"])
cli.main(["report", "--json"])
j = json.loads(capsys.readouterr().out)
# WAC 필드는 null (당일 net 99999로 채워지지 않음)
assert j["realized_pnl_wac"] is None