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
+23 -2
View File
@@ -25,7 +25,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional
from typing import Any, Callable, Optional
from uuid import UUID
from trex_core.application.ports import IntentStore
@@ -110,6 +110,7 @@ class IntentService:
max_active_slots: Optional[int] = None,
default_order_timeout_sec: Optional[int] = None,
warn_store=None, # Optional[ReconcileWarningStore] — BAT-050 / REQ-104
entry_allowed_fn: Optional[Callable[[], bool]] = None,
) -> None:
self._svc = slot_service
self._repo = slot_repo
@@ -128,6 +129,11 @@ class IntentService:
# BAT-050 / REQ-104: reconcile-warning gate store (Optional).
# When None, gate is skipped (backward compat, NFR-4).
self._warn_store = warn_store
# dcy-036 / REQ-114: day-start entry gate (Optional callable → bool).
# When None or returns True, gate passes (backward compat / opt-in=False).
# When returns False, BuyIntent is rejected with reason 'day_start_load_failed'.
# SellIntent is NOT gated by this (REQ-114 AC-5: 청산 경로 영향 없음).
self._entry_allowed_fn: Optional[Callable[[], bool]] = entry_allowed_fn
# ------------------------------------------------------------
def lookup(self, intent_id: str) -> Optional[IntentDecision]:
@@ -196,7 +202,22 @@ class IntentService:
position; M cap does NOT apply (active asset count unchanged).
- PENDING_* slot -> in-flight conflict (already_active).
Single intent thread + instance-scoped advisory lock serialize
admissions (dcy-020 OQ-1)."""
admissions (dcy-020 OQ-1).
dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False,
BuyIntent is rejected before any slot lookup."""
# dcy-036 / REQ-114: day-start entry gate (fail-closed).
# None or True → pass; False → reject new entry.
# SellIntent is NOT checked (청산 경로는 차단하지 않음 AC-5).
if self._entry_allowed_fn is not None:
try:
if not self._entry_allowed_fn():
return IntentDecision(
intent.intent_id, "rejected", None, "day_start_load_failed"
)
except Exception: # noqa: BLE001 - gate failure → fail-open (conservative)
pass
def _resolve(sl):
pid = sl.portfolio_id
+44 -1
View File
@@ -77,8 +77,36 @@ class RealizedPnlResult:
total_realized_pnl: float
# ---------------------------------------------------------------- broker credential (REQ-112)
@dataclass(frozen=True)
class BrokerCredentialPayload:
"""KIS broker credential fetched from portfolio-hub (REQ-112).
Carries app_key/app_secret/account_ref for KIS API authentication.
credential_version is optional (portfolio-hub versioning, informational only).
NFR-2: app_secret must never be logged in plaintext.
"""
account_ref: str
app_key: str
app_secret: str
credential_version: Optional[int] = None
@dataclass(frozen=True)
class TradingContext:
"""Combined broker credential + trading_enabled flag from /v1/user_info (REQ-112).
Returned by fetch_broker_credential() in a single atomic read to avoid
double HTTP calls.
"""
broker: Optional[BrokerCredentialPayload]
trading_enabled: bool
class PortfolioHubReadPort(Protocol):
"""Read-side contract for portfolio-hub universe (REQ-027) + realized PnL (REQ-109)."""
"""Read-side contract for portfolio-hub universe (REQ-027) + realized PnL (REQ-109)
+ broker credential (REQ-112)."""
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
"""Return all portfolios where `managed_by_daemon_id == daemon_id`."""
@@ -117,6 +145,21 @@ class PortfolioHubReadPort(Protocol):
"""
...
def fetch_broker_credential(self) -> "TradingContext":
"""GET /v1/user_info — broker credential + trading_enabled (REQ-112).
Returns a TradingContext with:
- broker: BrokerCredentialPayload if the daemon has assigned credentials,
None if broker is null (no credential assigned) or credential_payload
is null (decryption failure).
- trading_enabled: bool as reported by portfolio-hub.
Raises:
PortfolioHubUnavailableError: on network error, non-2xx (incl. 403),
or JSON parse failure.
"""
...
class DegradedReason(str, Enum):
"""Machine-readable degraded reason codes (REQ-028 AC-2, dcy-005 OQ-5)."""
+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()