feat(bat-053): 그날-시작 KIS 인증정보 fetch + fail-safe 로드 (REQ-112~115)

REQ-112: BrokerCredentialPayload/TradingContext 데이터클래스 + PortfolioHubReadPort.fetch_broker_credential() 포트 메서드 + hub_read 어댑터(_fetch_user_info 헬퍼 추출) + stub_read 결정론 stub
REQ-113: DaemonInstanceConfig/DaemonConfig.credential_fetch_enabled opt-in(기본 False) + TOML 파싱 + break-glass(TREX_KIS_BROKER_BREAK_GLASS) 감지
REQ-114: SnapshotScheduler._maybe_fetch_credential() — fail-closed 3조건(unavailable/broker-null/trading_disabled) + CRITICAL 알람 DAY_START_LOAD_FAILED + IntentService entry_allowed_fn 게이트(BuyIntent 차단, SellIntent 통과)
REQ-115: 그날 고정 캐시(_cached_credential/_credential_date) — 동일 날짜 재fetch 방지 + app_secret 메모리 한정(Redis 미기록)

스텁 기반 구현: 실 portfolio-hub/네트워크 없이 AC 전부 단위 테스트.
회귀: credential_fetch_enabled=False(기본) 경로 byte-for-byte 보존.
745 passed, 47 skipped (0 failed).

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 11:00:18 +09:00
co-authored by Claude Sonnet 4.6
parent f9c4fb4eba
commit e4e5c70422
16 changed files with 1175 additions and 21 deletions
+138 -1
View File
@@ -10,18 +10,21 @@ BAT-050 / REQ-103: reconcile now accepts warn_store for durable reconcile-warnin
from __future__ import annotations
import logging
import os
import threading
from datetime import datetime, time, timedelta, timezone
from datetime import date, datetime, time, timedelta, timezone
from typing import Callable, Optional
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.broker_port import BrokerPort
from trex_core.application.portfolio import (
BrokerCredentialPayload,
DegradedReason,
PortfolioHubReadPort,
PortfolioHubUnavailableError,
ReconcileWarningStore,
SnapshotStore,
TradingContext,
)
from trex_core.application.reconciliation import reconcile_positions
@@ -46,6 +49,7 @@ class SnapshotScheduler:
instance_key: str = "default",
alarm_emitter: Optional[AlarmEmitter] = None,
warn_store: Optional[ReconcileWarningStore] = None,
credential_fetch_enabled: bool = False,
) -> None:
self._read = read_port
self._store = store
@@ -65,6 +69,20 @@ class SnapshotScheduler:
self._alarm_emitter = alarm_emitter
# BAT-050 / REQ-103: durable reconcile-warning store (Optional).
self._warn_store = warn_store
# dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection.
# credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch.
self._credential_fetch_enabled: bool = credential_fetch_enabled
# REQ-113: detect break-glass at init time (env var set by operator).
self._break_glass: bool = (
os.environ.get("TREX_KIS_BROKER_BREAK_GLASS", "").lower()
in ("1", "true", "yes")
)
# REQ-115: day-scoped credential cache. None until first successful fetch.
self._cached_credential: Optional[BrokerCredentialPayload] = None
self._credential_date: Optional[date] = None
# REQ-114: entry gate. True = new entries allowed (default).
# credential_fetch_enabled=False keeps this always True (no gate).
self._entry_allowed: bool = True
# ---- callable from a daemon thread loop --------------------------------
def run_forever(self, stop: threading.Event, *, tick_sec: float = 60.0) -> None:
@@ -87,9 +105,128 @@ class SnapshotScheduler:
return # already fired today
self.run_once()
# ---- entry-allowed gate (REQ-114) ------------------------------------
def get_entry_allowed(self) -> bool:
"""Return current day-start entry gate state (REQ-114).
True = new entries allowed (default, or credential_fetch_enabled=False).
False = day-start load failed (fail-closed); BuyIntent admission blocked.
Thread-safe: _entry_allowed is set only in run_once() (scheduler thread).
"""
return self._entry_allowed
# ---- credential fetch hook (REQ-112/113/114/115) ----------------------
def _maybe_fetch_credential(self) -> None:
"""Attempt credential fetch at day-start checkpoint (REQ-114).
Called at the start of run_once() when credential_fetch_enabled=True.
Applies priority:
1. break-glass (TREX_KIS_BROKER_BREAK_GLASS=true): skip fetch, log INFO.
2. day-scoped cache hit (already fetched today): skip re-fetch (REQ-115 AC-3).
3. fetch from portfolio-hub (REQ-112): cache on success, fail-closed on failure.
Side effects:
- Sets self._entry_allowed (True on success/break-glass, False on failure).
- Sets self._cached_credential / self._credential_date on success.
- Emits CRITICAL alarm DAY_START_LOAD_FAILED on fail-closed (REQ-114).
- NFR-2: app_secret is never logged in plaintext.
"""
if self._break_glass:
_log.info(
"credential fetch: break-glass active (TREX_KIS_BROKER_BREAK_GLASS=true) "
"— using env KIS config; fetch skipped (instance=%s)",
self._instance_key,
)
# break-glass: treat as env-only, entry allowed
self._entry_allowed = True
return
today = self._clock().astimezone(_KST).date()
if self._credential_date is not None and self._credential_date == today:
# REQ-115 AC-3: day-scoped cache hit — no re-fetch
_log.debug(
"credential fetch: cache hit for %s (instance=%s)",
today, self._instance_key,
)
return
# Attempt fetch
try:
ctx: TradingContext = self._read.fetch_broker_credential()
except PortfolioHubUnavailableError as exc:
_log.error(
"DAY_START_LOAD_FAILED: portfolio-hub unavailable at day-start "
"(instance=%s): %s",
self._instance_key, exc,
)
self._entry_allowed = False
self._emit_day_start_alarm(reason="portfolio_hub_unavailable")
return
except Exception as exc: # noqa: BLE001 - unexpected failure = fail-closed
_log.error(
"DAY_START_LOAD_FAILED: unexpected error fetching broker credential "
"(instance=%s): %s",
self._instance_key, exc.__class__.__name__,
)
self._entry_allowed = False
self._emit_day_start_alarm(reason="unexpected_error")
return
# Evaluate fail-closed conditions (REQ-114)
if ctx.broker is None:
_log.error(
"DAY_START_LOAD_FAILED: broker credential absent (broker=null) "
"(instance=%s)",
self._instance_key,
)
self._entry_allowed = False
self._emit_day_start_alarm(reason="broker_null")
return
if not ctx.trading_enabled:
_log.error(
"DAY_START_LOAD_FAILED: trading_enabled=False at day-start "
"(instance=%s)",
self._instance_key,
)
self._entry_allowed = False
self._emit_day_start_alarm(reason="trading_disabled")
return
# Success: cache + allow entry
self._cached_credential = ctx.broker
self._credential_date = today
self._entry_allowed = True
# NFR-2: log account_ref only, never app_secret
_log.info(
"DAY_START credential loaded: account_ref=%s trading_enabled=%s "
"credential_version=%s (instance=%s, source=fetch)",
ctx.broker.account_ref,
ctx.trading_enabled,
ctx.broker.credential_version,
self._instance_key,
)
def _emit_day_start_alarm(self, *, reason: str) -> None:
"""Publish CRITICAL DAY_START_LOAD_FAILED alarm (REQ-114)."""
if self._alarm_emitter is None:
return
self._alarm_emitter.publish_alarm(
"DAY_START_LOAD_FAILED",
"그날-시작 KIS 인증정보 로드 실패: 신규 진입 차단",
fields={"reason": reason, "instance_key": self._instance_key},
severity="critical",
)
# ---- the actual refresh -----------------------------------------------
def run_once(self) -> bool:
"""Fetch + swap + reconcile. Returns True on success, False if degraded."""
# dcy-036 / REQ-114: day-start credential fetch hook (opt-in).
# Must run before portfolio snapshot so entry_allowed is set before
# the first BuyIntent can arrive from the strategy daemon.
if self._credential_fetch_enabled:
self._maybe_fetch_credential()
if self._daemon_id is None:
try:
self._daemon_id = self._read.whoami()