fix(broker): enforce operator broker truth boundary

This commit is contained in:
Hermes Agent
2026-07-15 08:07:21 +09:00
parent 1b63e79edc
commit 216e462df4
19 changed files with 603 additions and 35 deletions
+36 -1
View File
@@ -10,7 +10,7 @@ import os
import pytest
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.broker_port import OrderRequest
from trex_core.application.broker_port import BrokerBalanceError, OrderRequest
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
@@ -75,3 +75,38 @@ def test_sandbox_token_and_submit():
assert ack.broker_order_id
else:
assert ack.reason # rejection carries a reason (e.g., 장 미개장/잔고부족)
def test_get_balance_rejects_failure_instead_of_false_empty(monkeypatch):
"""BAT-057: a rejected KIS response is not a successful empty balance."""
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
def raise_for_status(self): pass
def json(self): return {"rt_cd": "1", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
with pytest.raises(BrokerBalanceError, match="broker_rejected"):
b.get_balance()
def test_get_balance_accepts_validated_empty_balance(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
def raise_for_status(self): pass
def json(self): return {"rt_cd": "0", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
assert b.get_balance() == []
def test_get_balance_sanitizes_token_issuance_failure(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret")))
with pytest.raises(BrokerBalanceError) as raised:
b.get_balance()
assert raised.value.code == "token_issuance_failed"
assert "secret" not in str(raised.value)
+83 -1
View File
@@ -1,7 +1,10 @@
"""dcy-020 / REQ-062 (BAT-029): core holding ↔ broker divergence reconcile."""
from __future__ import annotations
from dataclasses import replace
from trex_core.application.broker_port import BalanceItem
from trex_core.application.portfolio import InMemoryReconcileWarningStore
from trex_core.application.reconciliation import reconcile_core_holdings
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@@ -36,6 +39,18 @@ def _wire():
return repo, SlotService(repo, instance_key=INST)
class TrackingRepository(InMemorySlotRepository):
"""Records writes so reconciliation-only classification stays read-only."""
def __init__(self):
super().__init__()
self.update_calls = 0
def update_slot(self, slot):
self.update_calls += 1
return super().update_slot(slot)
def test_A_core_holding_broker_flat_goes_error():
repo, svc = _wire()
slot = _hold(repo, svc, "pf-a", "005930", 10)
@@ -66,4 +81,71 @@ def test_B_broker_holds_unmanaged_warns():
repo, svc = _wire()
broker = FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]) # core has none
d = reconcile_core_holdings(slot_repo=repo, slot_service=svc, broker=broker, instance_key=INST)
assert d["broker:000660"] == "unmanaged_warning"
assert d["broker:000660"] == "unmanaged_position"
def test_broker_held_symbol_with_same_instance_non_holding_slot_is_read_only_divergence():
repo = TrackingRepository()
svc = SlotService(repo, instance_key=INST)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe(INST, {"pf-a": "000660"})
slot = repo.find_slot_by_portfolio(INST, "pf-a")
assert slot is not None
writes_before = repo.update_calls
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc,
broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]),
instance_key=INST, warn_store=warn_store,
)
assert decisions == {"broker:000660": "managed_non_holding_divergence"}
assert repo.fetch_slot(slot.slot_id) == slot
assert repo.update_calls == writes_before
assert warn_store.list_warned() == set()
assert repo.list_slots(INST) == [slot]
def test_broker_held_symbol_with_same_instance_error_slot_is_read_only_divergence():
repo = TrackingRepository()
svc = SlotService(repo, instance_key=INST)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe(INST, {"pf-a": "000660"})
inactive = repo.find_slot_by_portfolio(INST, "pf-a")
assert inactive is not None
error = replace(inactive, status=SlotState.ERROR)
repo.update_slot(error)
writes_before = repo.update_calls
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc,
broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]),
instance_key=INST, warn_store=warn_store,
)
assert decisions == {"broker:000660": "managed_non_holding_divergence"}
assert repo.fetch_slot(error.slot_id) == error
assert repo.update_calls == writes_before
assert warn_store.list_warned() == set()
def test_other_instance_only_managed_slot_remains_unmanaged_position_without_writes():
repo = TrackingRepository()
svc = SlotService(repo, instance_key=INST)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe("other-instance", {"pf-a": "000660"})
other = repo.find_slot_by_portfolio("other-instance", "pf-a")
assert other is not None
writes_before = repo.update_calls
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc,
broker=FakeBroker([BalanceItem(symbol="000660", hldg_qty=5)]),
instance_key=INST, warn_store=warn_store,
)
assert decisions == {"broker:000660": "unmanaged_position"}
assert repo.fetch_slot(other.slot_id) == other
assert repo.update_calls == writes_before
assert warn_store.list_warned() == set()
assert repo.list_slots(INST) == []
@@ -130,7 +130,7 @@ def test_reconcile_core_holdings_matching_qty_no_warn(caplog):
instance_key="default", warn_store=warn_store,
)
assert warn_store.list_warned() == set() # S-4: no false positive
assert all(v == "ok" for v in decisions.values() if v != "unmanaged_warning")
assert all(v == "ok" for v in decisions.values() if v != "unmanaged_position")
# REQ-103 AC-4: A case (broker 0) => ERROR transition, NOT warn_store
+83
View File
@@ -0,0 +1,83 @@
"""REQ-119 provider source and refresh lifecycle regressions."""
from types import SimpleNamespace
import pytest
from trex_core.runtime.broker_provider import (
BrokerCredentialUnavailable,
ConfigBrokerProvider,
)
from trex_core.runtime.config import DaemonConfig, KISConfig, PortfolioHubConfig
def _cfg(*, fetched=True, broker="kis"):
return DaemonConfig(
dsn="postgresql://test/test", broker=broker, execution_interval_sec=1,
kis=KISConfig("https://openapivts.example", "env-key", "env-secret", "env-account", 1, False),
portfolio_hub=PortfolioHubConfig("http://hub"), credential_fetch_enabled=fetched,
)
class _Broker:
def __init__(self): self.credentials = []
def reinitialize(self, credential): self.credentials.append(credential)
class _Read:
def __init__(self, ctx=None, error=None): self.ctx, self.error, self.calls = ctx, error, 0
def fetch_broker_credential(self):
self.calls += 1
if self.error:
raise self.error
return self.ctx
def _ctx(version="v1"):
return SimpleNamespace(trading_enabled=True, broker=SimpleNamespace(
account_ref="synthetic-account", app_key="synthetic-key", app_secret="synthetic-secret",
credential_version=version,
))
def test_fetched_provider_constructs_once_reuses_and_refreshes_without_env_fallback(monkeypatch):
broker = _Broker()
monkeypatch.setattr("trex_core.runtime.broker_provider._make_env_broker", lambda cfg: broker)
read = _Read(_ctx())
provider = ConfigBrokerProvider(_cfg(), read_port=read)
got, source = provider.get_broker_for_read()
assert got is broker
assert source.mode == "fetched"
assert source.identity_label == "credential-version:v1"
assert broker.credentials[0].app_key == "synthetic-key"
assert read.calls == 1
assert provider.refresh_day_start_credential() == source
assert read.calls == 1 # same day cache, no broker/token churn
def test_fetched_provider_failure_is_sanitized_and_never_constructs_env_broker(monkeypatch):
monkeypatch.setattr("trex_core.runtime.broker_provider._make_env_broker", lambda cfg: pytest.fail("env fallback"))
provider = ConfigBrokerProvider(_cfg(), read_port=_Read(ctx=None, error=RuntimeError("secret payload")))
with pytest.raises(BrokerCredentialUnavailable) as raised:
provider.get_broker_for_read()
assert raised.value.code == "portfolio_hub_unavailable"
assert "secret" not in str(raised.value)
def test_enabled_paper_never_fetches_kis_credentials(monkeypatch):
read = _Read(_ctx())
provider = ConfigBrokerProvider(_cfg(broker="paper"), read_port=read)
_, source = provider.get_broker_for_read()
assert source.mode == "env"
assert read.calls == 0
def test_break_glass_is_distinct_and_skips_fetch(monkeypatch):
broker = _Broker()
monkeypatch.setenv("TREX_KIS_BROKER_BREAK_GLASS", "true")
monkeypatch.setattr("trex_core.runtime.broker_provider._make_env_broker", lambda cfg: broker)
read = _Read(_ctx())
_, source = ConfigBrokerProvider(_cfg(), read_port=read).get_broker_for_read()
assert source.mode == "break_glass"
assert source.identity_label == "operator-break-glass"
assert read.calls == 0
+53
View File
@@ -0,0 +1,53 @@
"""REQ-120 CLI failure boundary regression tests."""
import sys
import types
from types import SimpleNamespace
# This worktree intentionally lacks pika. CLI imports only messaging topology for
# these tests; install a minimal import-time stub without contacting a broker.
_pika = types.ModuleType("pika")
_pika.__path__ = []
_pika.URLParameters = lambda *a, **k: None
_pika.BlockingConnection = object
_pika.BasicProperties = object
_pika_exceptions = types.ModuleType("pika.exceptions")
_pika_exceptions.AMQPError = Exception
_pika.exceptions = _pika_exceptions
sys.modules.setdefault("pika", _pika)
sys.modules.setdefault("pika.exceptions", _pika_exceptions)
from trex_core.application.broker_port import BrokerBalanceError # noqa: E402
from trex_core.runtime import cli # noqa: E402
from trex_core.runtime.config import DaemonConfig # noqa: E402
def _args(*, as_json=True):
return SimpleNamespace(portfolio_id="PF-1", all=False, json=as_json, dry_run=True,
yes=True, live=False, instance=None, config=None, config_path=None)
def test_sync_balance_failure_is_sanitized_and_happens_before_db(monkeypatch, capsys):
class Provider:
def get_broker_for_read(self):
return SimpleNamespace(get_balance=lambda: (_ for _ in ()).throw(BrokerBalanceError("authentication_failed"))), None
monkeypatch.setattr("trex_core.runtime.broker_provider.build_broker_provider", lambda cfg: Provider())
# A DB object being constructed would prove the boundary arrived too late.
monkeypatch.setattr("trex_core.adapters.portfolio.reconcile_warning_store.PostgreSQLReconcileWarningStore",
lambda *a: (_ for _ in ()).throw(AssertionError("DB touched")))
assert cli._cmd_sync(_args(), DaemonConfig("dsn", "paper", 1), lambda _: True) == 1
captured = capsys.readouterr()
assert '"authentication_failed"' in captured.err
assert "broker balance unavailable" in captured.err
def test_verify_balance_failure_never_leaks_payload_or_opens_db(monkeypatch, capsys):
class Provider:
def get_broker_for_read(self):
return SimpleNamespace(get_balance=lambda: (_ for _ in ()).throw(BrokerBalanceError("transport_failed"))), None
monkeypatch.setattr("trex_core.runtime.broker_provider.build_broker_provider", lambda cfg: Provider())
monkeypatch.setattr("trex_core.adapters.persistence.postgresql.PostgreSQLSlotRepository",
lambda *a: (_ for _ in ()).throw(AssertionError("DB touched")))
assert cli._cmd_verify(_args(), DaemonConfig("dsn", "paper", 1)) == 1
captured = capsys.readouterr()
assert '"transport_failed"' in captured.err
assert "payload-secret" not in captured.err