diff --git a/tests/adapters/broker/test_ownership.py b/tests/adapters/broker/test_ownership.py index 9a39301..8e91bfd 100644 --- a/tests/adapters/broker/test_ownership.py +++ b/tests/adapters/broker/test_ownership.py @@ -92,7 +92,24 @@ def test_strategy_quantity_above_raw_broker_total_fails_closed(tmp_path): assert exc.value.code == "ownership_mismatch" -def test_projection_includes_strategy_symbol_alongside_first_page_manual_holding(tmp_path): +def _paged_kis_for_ownership(kis_balance_broker_factory, strategy_qty): + return kis_balance_broker_factory([ + { + "rows": [{"pdno": "005930", "hldg_qty": "19"}], + "tr_cont": "F", + "fk": "A", + "nk": "1", + }, + { + "rows": [{"pdno": "000660", "hldg_qty": str(strategy_qty)}], + "tr_cont": "E", + }, + ]) + + +def test_projection_includes_strategy_symbol_from_actual_later_kis_page( + tmp_path, kis_balance_broker_factory +): store = StrategyOwnershipStore(tmp_path / "ownership.json") store.apply_fill( execution_id="buy-1", @@ -100,12 +117,32 @@ def test_projection_includes_strategy_symbol_alongside_first_page_manual_holding side="buy", quantity=4, ) - broker = OwnershipAwareBroker( - FakeBroker([BalanceItem("005930", 19), BalanceItem("000660", 4)]), - store, - ) + delegate, calls = _paged_kis_for_ownership(kis_balance_broker_factory, 4) + broker = OwnershipAwareBroker(delegate, store) assert broker.get_balance() == [BalanceItem("000660", 4)] + assert len(calls) == 2 + assert "tr_cont" not in calls[0]["headers"] + assert calls[1]["headers"]["tr_cont"] == "N" + + +def test_projection_detects_mismatch_from_actual_later_kis_page( + tmp_path, kis_balance_broker_factory +): + store = StrategyOwnershipStore(tmp_path / "ownership.json") + store.apply_fill( + execution_id="buy-1", + symbol="000660", + side="buy", + quantity=4, + ) + delegate, calls = _paged_kis_for_ownership(kis_balance_broker_factory, 3) + broker = OwnershipAwareBroker(delegate, store) + + with pytest.raises(BrokerBalanceError) as exc: + broker.get_balance() + assert exc.value.code == "ownership_mismatch" + assert len(calls) == 2 def test_sell_cannot_reduce_below_strategy_owned_zero(tmp_path): diff --git a/tests/application/test_holding_divergence.py b/tests/application/test_holding_divergence.py index f61866a..faa55ac 100644 --- a/tests/application/test_holding_divergence.py +++ b/tests/application/test_holding_divergence.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import replace +from unittest.mock import Mock import pytest @@ -26,11 +27,6 @@ class FakeBroker: return list(self._b) -class FailingBroker(FakeBroker): - def get_balance(self): - raise BrokerBalanceError("malformed_response") - - def _hold(repo, svc, pid, symbol, qty): repo.reconcile_universe(INST, {pid: "SYM"}) slot = repo.find_slot_by_portfolio(INST, pid) @@ -84,19 +80,24 @@ def test_ok_when_qty_matches(): assert d[str(slot.slot_id)] == "ok" -def test_later_page_snapshot_symbol_keeps_holding_slot_ok(): +def test_later_page_snapshot_symbol_keeps_holding_slot_ok(kis_balance_broker_factory): repo, svc = _wire() slot = _hold(repo, svc, "pf-later", "000660", 4) + broker, calls = kis_balance_broker_factory([ + {"rows": [], "tr_cont": "F", "fk": "A", "nk": "1"}, + {"rows": [{"pdno": "000660", "hldg_qty": "4"}], "tr_cont": "E"}, + ]) decisions = reconcile_core_holdings( slot_repo=repo, slot_service=svc, - broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=4)]), + broker=broker, instance_key=INST, ) assert decisions == {str(slot.slot_id): "ok"} assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + assert len(calls) == 2 def test_B_broker_holds_unmanaged_warns(): @@ -173,16 +174,39 @@ def test_other_instance_only_managed_slot_remains_unmanaged_position_without_wri assert repo.list_slots(INST) == [] -def test_reconcile_core_holdings_propagates_balance_failure_without_slot_write(): - repo, svc = _wire() +def test_reconcile_core_holdings_propagates_later_page_failure_without_write( + kis_balance_broker_factory, monkeypatch +): + repo = TrackingRepository() + svc = SlotService(repo, instance_key=INST) slot = _hold(repo, svc, "pf-a", "005930", 10) + writes_before = repo.update_calls + warn_store = InMemoryReconcileWarningStore() + set_warned = Mock(wraps=warn_store.set_warned) + clear_warned = Mock(wraps=warn_store.clear_warned) + monkeypatch.setattr(warn_store, "set_warned", set_warned) + monkeypatch.setattr(warn_store, "clear_warned", clear_warned) + broker, calls = kis_balance_broker_factory([ + { + "rows": [{"pdno": "005930", "hldg_qty": "10"}], + "tr_cont": "F", + "fk": "A", + "nk": "1", + }, + {"rows": [], "tr_cont": "X"}, + ]) with pytest.raises(BrokerBalanceError, match="malformed_response"): reconcile_core_holdings( slot_repo=repo, slot_service=svc, - broker=FailingBroker([]), + broker=broker, instance_key=INST, + warn_store=warn_store, ) + assert len(calls) == 2 assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING + assert repo.update_calls == writes_before + set_warned.assert_not_called() + clear_warned.assert_not_called() diff --git a/tests/application/test_reconciliation.py b/tests/application/test_reconciliation.py index 9a7876d..0705bbc 100644 --- a/tests/application/test_reconciliation.py +++ b/tests/application/test_reconciliation.py @@ -1,10 +1,16 @@ """Position reconciliation tests (REQ-031).""" from __future__ import annotations +from unittest.mock import Mock + import pytest from trex_core.application.broker_port import BalanceItem, BrokerBalanceError -from trex_core.application.portfolio import PortfolioItem, SnapshotStore +from trex_core.application.portfolio import ( + InMemoryReconcileWarningStore, + PortfolioItem, + SnapshotStore, +) from trex_core.application.reconciliation import reconcile_positions @@ -18,11 +24,6 @@ class FakeBroker: def get_balance(self): return list(self._b) -class FailingBroker(FakeBroker): - def get_balance(self): - raise BrokerBalanceError("malformed_response") - - def _store_with(*items): store = SnapshotStore() store.swap(list(items)) @@ -54,18 +55,22 @@ def test_subsequent_match_auto_clears_warning(): assert store.warnings() == set() -def test_reconcile_positions_accepts_symbol_from_complete_later_page_snapshot(): +def test_reconcile_positions_accepts_symbol_from_complete_later_page_snapshot( + kis_balance_broker_factory, +): store = _store_with( PortfolioItem(portfolio_id="pf-later", symbol="000660", cash_balance=0, asset_quantity=4), ) + broker, calls = kis_balance_broker_factory([ + {"rows": [], "tr_cont": "F", "fk": "A", "nk": "1"}, + {"rows": [{"pdno": "000660", "hldg_qty": "4"}], "tr_cont": "E"}, + ]) - decisions = reconcile_positions( - store=store, - broker=FakeBroker([BalanceItem("000660", 4)]), - ) + decisions = reconcile_positions(store=store, broker=broker) assert decisions == {"pf-later": "ok"} assert store.warnings() == set() + assert len(calls) == 2 def test_multiple_portfolios_same_symbol_aggregate(): @@ -109,12 +114,41 @@ def test_paper_broker_returns_deterministic_balance(): assert "005930" in syms -def test_reconcile_positions_propagates_balance_failure_without_warning_write(): - store = _store_with( +def test_reconcile_positions_propagates_later_page_failure_without_warning_write( + kis_balance_broker_factory, monkeypatch +): + store = SnapshotStore() + store.swap([ PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10), - ) + ]) + store.set_warning("pf-A", True) + warning_write = Mock(wraps=store.set_warning) + monkeypatch.setattr(store, "set_warning", warning_write) + warn_store = InMemoryReconcileWarningStore() + set_warned = Mock(wraps=warn_store.set_warned) + clear_warned = Mock(wraps=warn_store.clear_warned) + monkeypatch.setattr(warn_store, "set_warned", set_warned) + monkeypatch.setattr(warn_store, "clear_warned", clear_warned) + broker, calls = kis_balance_broker_factory([ + { + "rows": [{"pdno": "005930", "hldg_qty": "10"}], + "tr_cont": "F", + "fk": "A", + "nk": "1", + }, + {"rows": [], "tr_cont": "X"}, + ]) with pytest.raises(BrokerBalanceError, match="malformed_response"): - reconcile_positions(store=store, broker=FailingBroker([])) + reconcile_positions( + store=store, + broker=broker, + slot_repo=object(), + warn_store=warn_store, + ) - assert store.warnings() == set() + assert len(calls) == 2 + assert store.warnings() == {"pf-A"} + warning_write.assert_not_called() + set_warned.assert_not_called() + clear_warned.assert_not_called() diff --git a/tests/conftest.py b/tests/conftest.py index 4659215..efedc80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,8 @@ import os import pytest +from trex_core.adapters.broker.kis import KISBroker + _PG_TABLES = ( "broker_account_snapshot", "killswitch_exclusion", @@ -26,6 +28,53 @@ _PG_TABLES = ( ) +@pytest.fixture +def kis_balance_broker_factory(monkeypatch): + """Build a real KISBroker backed by deterministic synthetic balance pages.""" + + def factory(responses): + broker = KISBroker( + "12345678-01", + app_key="test-app-key", + app_secret="test-app-secret", + base_url="https://openapivts.kis.test", + ) + monkeypatch.setattr(broker, "_headers", lambda _tr: {}) + pending = list(responses) + calls = [] + + class Response: + def __init__(self, page): + self._page = page + self.headers = {"tr_cont": page.get("tr_cont", "E")} + + def raise_for_status(self): + return None + + def json(self): + return { + "rt_cd": self._page.get("rt_cd", "0"), + "output1": self._page.get("rows", []), + "ctx_area_fk100": self._page.get("fk", ""), + "ctx_area_nk100": self._page.get("nk", ""), + } + + def fake_get(*_args, **kwargs): + calls.append({ + "headers": dict(kwargs["headers"]), + "params": dict(kwargs["params"]), + }) + response = pending.pop(0) + if isinstance(response, BaseException): + raise response + return Response(response) + + monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get) + return broker, calls + + return factory + + @pytest.fixture def pg_dsn() -> str: dsn = os.environ.get("TREX_POSTGRESQL_TEST_DSN")