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
+50 -2
View File
@@ -20,10 +20,12 @@ from typing import Optional
import httpx
from trex_core.application.portfolio import (
BrokerCredentialPayload,
PortfolioHubUnavailableError,
PortfolioItem,
RealizedPnlItem,
RealizedPnlResult,
TradingContext,
)
@@ -57,7 +59,12 @@ class PortfolioHubReadAdapter:
return httpx.get(url, params=params, headers=self._headers(),
timeout=self._timeout)
def whoami(self) -> str:
def _fetch_user_info(self) -> dict:
"""Internal helper: GET /v1/user_info and return parsed JSON body.
Raises PortfolioHubUnavailableError on network error, non-2xx, or parse
failure. Shared by whoami() and fetch_broker_credential() (REQ-112).
"""
url = f"{self._base_url}/v1/user_info"
try:
resp = self._get(url)
@@ -73,11 +80,52 @@ class PortfolioHubReadAdapter:
body = resp.json()
except ValueError as exc:
raise PortfolioHubUnavailableError("user_info response not JSON") from exc
did = body.get("daemon_id") if isinstance(body, dict) else None
if not isinstance(body, dict):
raise PortfolioHubUnavailableError("user_info response not a JSON object")
return body
def whoami(self) -> str:
body = self._fetch_user_info()
did = body.get("daemon_id")
if not did:
raise PortfolioHubUnavailableError("user_info missing daemon_id")
return str(did)
def fetch_broker_credential(self) -> TradingContext:
"""GET /v1/user_info — broker credential + trading_enabled (REQ-112).
Returns TradingContext. broker=None when broker field is null or
credential_payload is null (decryption failure — treated as absent).
NFR-2: app_secret is never logged in plaintext here.
Raises PortfolioHubUnavailableError on network/non-2xx/403/parse errors.
"""
body = self._fetch_user_info()
trading_enabled: bool = bool(body.get("trading_enabled", False))
broker_raw = body.get("broker")
if not isinstance(broker_raw, dict):
# broker: null — no credential assigned for this daemon
return TradingContext(broker=None, trading_enabled=trading_enabled)
credential_payload = broker_raw.get("credential_payload")
if not isinstance(credential_payload, dict):
# credential_payload: null — decryption failure; treat as absent
return TradingContext(broker=None, trading_enabled=trading_enabled)
app_key = credential_payload.get("app_key") or ""
app_secret = credential_payload.get("app_secret") or ""
account_ref = broker_raw.get("account_ref") or ""
raw_ver = broker_raw.get("credential_version")
credential_version = int(raw_ver) if raw_ver is not None else None
payload = BrokerCredentialPayload(
account_ref=str(account_ref),
app_key=str(app_key),
app_secret=str(app_secret),
credential_version=credential_version,
)
# NFR-2: do NOT log app_secret. Log only account_ref + app_key prefix.
return TradingContext(broker=payload, trading_enabled=trading_enabled)
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
url = f"{self._base_url}/v1/portfolios"
try:
+34 -1
View File
@@ -1,12 +1,16 @@
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2; REQ-109 stub)."""
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2; REQ-109 stub;
REQ-112 broker credential stub)."""
from __future__ import annotations
from typing import Optional
from trex_core.application.portfolio import (
BrokerCredentialPayload,
PortfolioHubUnavailableError,
PortfolioItem,
RealizedPnlItem,
RealizedPnlResult,
TradingContext,
)
@@ -18,6 +22,10 @@ class StubPortfolioRead:
REQ-109: `get_realized_pnl` returns a deterministic fixed RealizedPnlResult.
Tests can override `self.realized_pnl_result` to inject custom fixtures.
REQ-112: `fetch_broker_credential` returns a deterministic TradingContext.
Tests can override `self.trading_context` or set `self.fetch_broker_raises`
to simulate failure scenarios without network.
"""
def __init__(
@@ -25,6 +33,8 @@ class StubPortfolioRead:
daemon_id: str = "stub-daemon",
items: list[PortfolioItem] | None = None,
realized_pnl_result: Optional[RealizedPnlResult] = None,
trading_context: Optional[TradingContext] = None,
fetch_broker_raises: bool = False,
) -> None:
self._daemon_id = daemon_id
self.items: list[PortfolioItem] = list(items or [
@@ -61,6 +71,19 @@ class StubPortfolioRead:
data_completeness_warning=False,
total_realized_pnl=50_000.0,
)
# REQ-112: fixed TradingContext for tests (overridable).
# Default: valid credential + trading_enabled=True (happy path).
self.trading_context: TradingContext = trading_context or TradingContext(
broker=BrokerCredentialPayload(
account_ref="stub-account",
app_key="stub-app-key",
app_secret="stub-app-secret",
credential_version=1,
),
trading_enabled=True,
)
# REQ-112 AC-4 scenario: set True to simulate PortfolioHubUnavailableError.
self.fetch_broker_raises: bool = fetch_broker_raises
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
# Stub does not filter by daemon_id — every test universe is scoped.
@@ -79,3 +102,13 @@ class StubPortfolioRead:
) -> RealizedPnlResult:
"""REQ-109 AC-4: deterministic fixed result, no network required."""
return self.realized_pnl_result
def fetch_broker_credential(self) -> TradingContext:
"""REQ-112 AC-6: deterministic fixed TradingContext, no network required.
Override self.trading_context to inject custom test fixtures.
Set self.fetch_broker_raises = True to simulate PortfolioHubUnavailableError.
"""
if self.fetch_broker_raises:
raise PortfolioHubUnavailableError("stub: simulated broker credential fetch failure")
return self.trading_context
+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()
+10
View File
@@ -158,6 +158,10 @@ class DaemonInstanceConfig:
# back to env TREX_WATCHDOG_TIMEOUT (default 120). Long-term instances
# polling every 1-10 min should set >= 1800 so expired stays meaningful.
watchdog_timeout_sec: Optional[int] = None
# dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default)
# = env-only KIS config (byte-for-byte backward compat). True = fetch 1st,
# TREX_KIS_BROKER_BREAK_GLASS=true = skip fetch and use env.
credential_fetch_enabled: bool = False
@staticmethod
def from_toml(path: str) -> "DaemonInstanceConfig":
@@ -196,6 +200,7 @@ class DaemonInstanceConfig:
default_order_timeout_sec=default_timeout,
safety_sell_enabled=bool(data.get("safety_sell_enabled", True)),
watchdog_timeout_sec=watchdog_timeout,
credential_fetch_enabled=bool(data.get("credential_fetch_enabled", False)),
)
@@ -242,6 +247,10 @@ class DaemonConfig:
ledger_retry_base_sec: float = 1.0
ledger_retry_cap_sec: float = 300.0
ledger_max_attempts: int = 8
# dcy-036 / REQ-113: opt-in portfolio-hub credential fetch. False (default)
# = env-only KIS config (byte-for-byte backward compat). True = fetch 1st,
# TREX_KIS_BROKER_BREAK_GLASS=true env = skip fetch and use env instead.
credential_fetch_enabled: bool = False
@staticmethod
def from_env() -> "DaemonConfig":
@@ -367,4 +376,5 @@ class DaemonConfig:
max_active_slots=inst.max_active_slots,
safety_sell_enabled=inst.safety_sell_enabled,
safety_sell=safety_sell,
credential_fetch_enabled=inst.credential_fetch_enabled,
)
+17 -8
View File
@@ -169,14 +169,6 @@ class Daemon:
self.pause_store = PostgreSQLPauseStore(config.dsn)
# BAT-050 / REQ-102: durable reconcile-warning store (slot_id PK).
self.warn_store = PostgreSQLReconcileWarningStore(config.dsn)
self.intent_service = IntentService(
self.slot_service, self.slot_repo, self.intent_store,
resolve_symbol=self._resolve_symbol,
instance_key=config.instance_key,
max_active_slots=config.max_active_slots, # dcy-020: asset-centric gate
default_order_timeout_sec=config.default_order_timeout_sec, # dcy-022
warn_store=self.warn_store, # BAT-050 / REQ-104: reconcile-warning gate
)
# BAT-012: day-scoped kill-switch exclusion store + service.
self.killswitch_store = PostgreSQLKillSwitchStore(config.dsn)
self.portfolio_read = _build_portfolio_read(config)
@@ -223,6 +215,23 @@ class Daemon:
instance_key=config.instance_key,
alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100
warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning
credential_fetch_enabled=config.credential_fetch_enabled, # dcy-036 / REQ-113
)
# dcy-036 / REQ-114: wire entry_allowed_fn after snapshot_scheduler is created.
# When credential_fetch_enabled=False (default), entry_allowed_fn=None = no gate.
entry_allowed_fn = (
self.snapshot_scheduler.get_entry_allowed
if config.credential_fetch_enabled
else None
)
self.intent_service = IntentService(
self.slot_service, self.slot_repo, self.intent_store,
resolve_symbol=self._resolve_symbol,
instance_key=config.instance_key,
max_active_slots=config.max_active_slots, # dcy-020: asset-centric gate
default_order_timeout_sec=config.default_order_timeout_sec, # dcy-022
warn_store=self.warn_store, # BAT-050 / REQ-104: reconcile-warning gate
entry_allowed_fn=entry_allowed_fn, # dcy-036 / REQ-114: day-start gate
)
self.execution = ExecutionService(
self.slot_service, self.slot_repo, self.broker,