feat(bat-054): 그날-시작 KIS 인증정보 양의 경로 주입 — KISBroker reinitialize (REQ-113 AC-2/AC-4, REQ-115 AC-1)

BAT-053 스캐폴드에서 연기된 양의 경로 완성:
- KISBroker.reinitialize(credential): app_key/app_secret/account_ref 교체 + L1 token 무효화
- SnapshotScheduler에 kis_reinit_callback 주입 지점 추가; 그날-시작 성공 시 callback 호출
- daemon.py에 _build_kis_reinit_callback() 헬퍼 + SnapshotScheduler 조립 수정
- 신규 테스트 12개 추가: 757 passed / 47 skipped (기존 745 대비 +12, 회귀 0)

안전 불변식 유지:
- credential_fetch_enabled=False(기본) 경로 byte-for-byte 보존
- fail-closed 불변식 미파괴 (reinit 실패 시 entry_allowed 유지)
- AMQP wire 계약 변경 없음

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-28 19:51:48 +09:00
co-authored by Claude Sonnet 4.6
parent a50b5e34a2
commit 6b066e19cb
10 changed files with 733 additions and 1 deletions
@@ -399,3 +399,273 @@ def test_intent_service_buy_passes_when_entry_allowed_fn_none():
BuyIntent(intent_id="b2", portfolio_id="PF-2", qty=5, entry_price=500)
)
assert decision.admitted
# ---------------------------------------------------------------------------
# BAT-054: REQ-113 AC-2 / REQ-115 AC-1 — KISBroker reinit callback
# ---------------------------------------------------------------------------
def test_kis_reinit_callback_called_on_success():
"""REQ-113 AC-2 / REQ-115 AC-1: fetch 성공 시 kis_reinit_callback 호출됨."""
from trex_core.application.portfolio import BrokerCredentialPayload
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub() # valid broker + trading_enabled=True
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
assert sched.get_entry_allowed() is True
# callback should have been called exactly once with the fetched credential
assert len(reinit_calls) == 1
assert isinstance(reinit_calls[0], BrokerCredentialPayload)
assert reinit_calls[0].account_ref == "stub-account"
assert reinit_calls[0].app_key == "stub-app-key"
# app_secret should be passed (for KISBroker reinit) but NOT logged
assert reinit_calls[0].app_secret == "stub-app-secret"
def test_kis_reinit_callback_not_called_when_fail_closed():
"""REQ-113 AC-2: fetch 실패(fail-closed) 시 kis_reinit_callback 호출 안 됨."""
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub(fetch_raises=True)
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
assert sched.get_entry_allowed() is False
# No reinit on failure
assert len(reinit_calls) == 0
def test_kis_reinit_callback_not_called_when_disabled():
"""REQ-113 AC-1 / REQ-115 회귀: credential_fetch_enabled=False → callback 호출 없음."""
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=False, # opt-in off (default)
kis_reinit_callback=fake_reinit, # callback present but should not fire
)
sched.run_once()
# no fetch, no reinit
assert len(reinit_calls) == 0
def test_kis_reinit_callback_failure_does_not_flip_entry_allowed():
"""REQ-113 AC-2 설계 D2: reinit callback 실패 시 entry_allowed를 False로 전환 안 함."""
def failing_reinit(credential):
raise RuntimeError("simulated KISBroker reinit failure")
read = _make_stub() # valid broker + trading_enabled=True
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=failing_reinit,
)
sched.run_once()
# reinit failure must NOT cause fail-closed
assert sched.get_entry_allowed() is True
def test_kis_reinit_callback_cached_for_day_not_called_twice():
"""REQ-115 AC-3 + REQ-113 AC-2: 그날 두 번째 run_once에서 callback 재호출 안 됨."""
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
assert len(reinit_calls) == 1
# Second run_once same day: cache hit, no re-fetch, no re-reinit
sched.run_once()
assert len(reinit_calls) == 1 # still 1
def test_kis_reinit_callback_none_when_break_glass(monkeypatch):
"""REQ-113 AC-3 + AC-2: break-glass 시 callback 호출 없음 (env 경로)."""
monkeypatch.setenv("TREX_KIS_BROKER_BREAK_GLASS", "true")
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
# break-glass: env-only path, no fetch, no reinit
assert len(reinit_calls) == 0
assert sched.get_entry_allowed() is True
# ---------------------------------------------------------------------------
# BAT-054: REQ-113 AC-4 보강 — break-glass 로그 content assert
# ---------------------------------------------------------------------------
def test_break_glass_log_contains_break_glass_keyword(monkeypatch, caplog):
"""REQ-113 AC-4 보강: break-glass 활성화 시 로그에 'break-glass' 키워드 포함."""
import logging
monkeypatch.setenv("TREX_KIS_BROKER_BREAK_GLASS", "true")
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
)
with caplog.at_level(logging.INFO):
sched.run_once()
log_lower = caplog.text.lower()
assert "break-glass" in log_lower or "break_glass" in log_lower
# ---------------------------------------------------------------------------
# BAT-054: REQ-115 AC-1 — KISBroker.reinitialize() 단위 테스트
# ---------------------------------------------------------------------------
def test_kis_broker_reinitialize_updates_credentials():
"""REQ-115 AC-1 / REQ-113 AC-2: KISBroker.reinitialize()가 인증정보를 교체함."""
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.portfolio import BrokerCredentialPayload
broker = KISBroker(
"old-account",
app_key="old-key",
app_secret="old-secret",
base_url="https://openapivts.koreainvestment.com:29443",
allow_live=False,
)
# Simulate a cached token
broker._token = "old-token"
new_cred = BrokerCredentialPayload(
account_ref="new-account-01234567-01",
app_key="new-app-key",
app_secret="new-app-secret",
credential_version=2,
)
broker.reinitialize(new_cred)
assert broker._account_ref == "new-account-01234567-01"
assert broker._app_key == "new-app-key"
assert broker._app_secret == "new-app-secret"
# L1 token should be cleared
assert broker._token is None
def test_kis_broker_reinitialize_preserves_base_url_and_flags():
"""REQ-115 AC-1: reinitialize()가 base_url/allow_live/_mock를 보존함."""
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.portfolio import BrokerCredentialPayload
broker = KISBroker(
"old-account",
app_key="old-key",
app_secret="old-secret",
base_url="https://openapivts.koreainvestment.com:29443",
allow_live=False,
)
original_base_url = broker._base_url
original_mock = broker._mock
original_timeout = broker._timeout
new_cred = BrokerCredentialPayload(
account_ref="new-account",
app_key="new-key",
app_secret="new-secret",
)
broker.reinitialize(new_cred)
assert broker._base_url == original_base_url
assert broker._mock == original_mock
assert broker._timeout == original_timeout
# ---------------------------------------------------------------------------
# BAT-054: daemon.py _build_kis_reinit_callback 단위 테스트
# ---------------------------------------------------------------------------
def test_build_kis_reinit_callback_returns_none_for_paper():
"""REQ-113 AC-2: broker=paper이면 callback=None."""
from trex_core.adapters.broker.paper import PaperBroker as PB
from trex_core.runtime.daemon import _build_kis_reinit_callback
from trex_core.runtime.config import DaemonConfig
# minimal config: paper broker, credential_fetch_enabled=True
cfg = DaemonConfig(dsn="x", broker="paper", execution_interval_sec=1.0,
credential_fetch_enabled=True)
result = _build_kis_reinit_callback(cfg, PB())
assert result is None
def test_build_kis_reinit_callback_returns_none_when_disabled():
"""REQ-113 AC-1: credential_fetch_enabled=False이면 callback=None."""
from trex_core.adapters.broker.paper import PaperBroker as PB
from trex_core.runtime.daemon import _build_kis_reinit_callback
from trex_core.runtime.config import DaemonConfig
cfg = DaemonConfig(dsn="x", broker="kis", execution_interval_sec=1.0,
credential_fetch_enabled=False)
result = _build_kis_reinit_callback(cfg, PB())
assert result is None
def test_build_kis_reinit_callback_returns_callable_for_kis():
"""REQ-113 AC-2 / REQ-115 AC-1: broker=kis + fetch enabled → callable 반환 + 실제 reinitialize 호출됨."""
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.portfolio import BrokerCredentialPayload
from trex_core.runtime.daemon import _build_kis_reinit_callback
from trex_core.runtime.config import DaemonConfig
cfg = DaemonConfig(dsn="x", broker="kis", execution_interval_sec=1.0,
credential_fetch_enabled=True)
broker = KISBroker(
"acct",
app_key="k",
app_secret="s",
base_url="https://openapivts.koreainvestment.com:29443",
allow_live=False,
)
result = _build_kis_reinit_callback(cfg, broker)
assert callable(result)
# verify calling the callback actually updates the broker
new_cred = BrokerCredentialPayload(
account_ref="new-acct",
app_key="new-k",
app_secret="new-s",
)
result(new_cred)
assert broker._account_ref == "new-acct"
assert broker._app_key == "new-k"