Files
trex-core/docs/batches/bat-053_20260628_daystart-credential-fetch-failsafe/design.md
T
genichinandClaude Sonnet 4.6 e4e5c70422 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
2026-06-28 11:00:18 +09:00

7.9 KiB

BAT-053: Design

Key Design Decisions

D1: entry_allowed 상태 위치 — SnapshotScheduler + 공유 callable

결정: SnapshotScheduler에 entry_allowed: bool = True (opt-in=False 기본 통과) 상태를 두고, IntentService에 entry_allowed_fn: Callable[[], bool] | None 게이트를 주입한다.

근거:

  • entry_allowed는 그날-시작 체크포인트(SnapshotScheduler.run_once())에서 설정되므로 소유자는 SnapshotScheduler가 자연스럽다.
  • IntentService는 이미 warn_store, max_active_slots 등 주입형 게이트 패턴을 사용한다. 동일 패턴으로 entry_allowed_fn: Optional[Callable[[], bool]] = None을 추가하면 기존 코드 변경 최소화.
  • entry_allowed_fn=None(기본)이면 게이트 완전 통과 → credential_fetch_enabled=False(기본)일 때 기존 동작 byte-for-byte 보존(회귀 0).
  • daemon.py에서 scheduler.get_entry_allowed 메서드를 IntentService에 주입하는 방식으로 조립.

대안 검토:

  • 별도 DayStartState 객체: 추가 클래스 생성 필요, SnapshotScheduler와 결합 불필요하게 증가.
  • IntentService 내부 setter: 의존 방향 역전(application이 application 내부를 조율하는 순환 구조) — 거부.

D2: credential_fetch_enabled 플래그 전파 경로

결정: DaemonInstanceConfig.credential_fetch_enabled: bool = False → DaemonConfig.from_config()에서 inst.credential_fetch_enabled를 읽어 DaemonConfig.credential_fetch_enabled: bool = False에 설정. SnapshotScheduler 생성자에 credential_fetch_enabled: bool = False 인자 추가.

근거:

  • TOML → DaemonInstanceConfig → DaemonConfig → SnapshotScheduler 흐름이 기존 safety_sell_enabled, watchdog_timeout_sec 패턴과 동일. 일관성 유지.
  • from_env() 는 변경 없음(always False). from_config()만 TOML 값을 읽어 전파.

D3: REQ-113 fetch 소스 우선순위 구현 위치

결정: 우선순위 로직은 DaemonConfig.from_config()에 두지 않고 SnapshotScheduler.run_once()의 그날-시작 후크에 둔다.

근거:

  • REQ-113 Notes: "base_url/timeout/allow_live는 env 유지". fetch는 app_key/app_secret/account_ref만 가져온다.
  • DaemonConfig.from_config()는 정적 구성 해결 단계 — 네트워크 I/O(fetch)를 여기에 두면 의존성 역전 위반(runtime이 portfolio-hub를 직접 호출).
  • SnapshotScheduler.run_once()는 이미 portfolio-hub를 호출하는 지점이고, credential_fetch_enabled=True일 때 추가 fetch가 자연스럽다.
  • break-glass(TREX_KIS_BROKER_BREAK_GLASS=true) 감지는 SnapshotScheduler 초기화 시 os.environ을 읽어 _break_glass: bool 플래그로 보관.
  • fetch 결과(BrokerCredentialPayload)는 SnapshotScheduler._cached_credential에 그날 고정 캐시.
  • KISConfig 재조립 경로: SnapshotScheduler에 kis_config_callback: Optional[Callable[[BrokerCredentialPayload], None]]를 주입. daemon.py에서 이 callback이 KISBroker를 재초기화하거나 config를 갱신하는 역할을 한다. 단, M3에서 중장기 인스턴스 추가 전까지는 단기 07:30 로드에서만 필요하고, BAT-053 범위에서는 entry_allowed 플래그 설정 + 알람까지만 구현. KISBroker 재조립은 M3 별도 batch.

BAT-053 범위 명확화:

  • REQ-113 AC-1~7 중 AC-2(fetch 결과로 KISConfig 구성)의 "KISBroker에 실제 적용" 부분은 M3 KISBroker 재조립 batch에 defer. 이번 batch에서는 fetch→캐시→entry_allowed 게이트 + 알람까지.
  • 단, AC-2 테스트는 TradingContext가 올바르게 반환됨을 stub으로 검증.

D4: 그날 고정 캐시 설계 (REQ-115)

결정: SnapshotScheduler에 인스턴스 변수 추가:

  • _cached_credential: Optional[BrokerCredentialPayload] = None
  • _credential_date: Optional[date] = None
  • _entry_allowed: bool = True (opt-in=False면 항상 True = 차단 안 함)

run_once() 시 credential_fetch_enabled=True이면 _maybe_fetch_credential() 메서드 호출. _maybe_fetch_credential():

  1. _credential_date == today → 그날 고정 캐시 반환(재fetch 안 함, REQ-115 AC-3)
  2. break-glass → env-only, 캐시 없음, _entry_allowed=True
  3. fetch_broker_credential() 호출 → 성공: 캐시 + _entry_allowed=True; 실패(3가지 조건): _entry_allowed=False + ERROR + CRITICAL 알람

get_entry_allowed() -> bool 메서드 노출 → daemon.py에서 IntentService.entry_allowed_fn에 주입.

D5: 중장기 인스턴스 후크 위치 (REQ-114 Notes)

결정: BAT-053에서는 단기 07:30 KST 경로(SnapshotScheduler.run_once())만 구현. 중장기 인스턴스(dcy-027 리밸런싱 주기)의 동등 후크는 BAT-053 구현 후 그 위치를 확인하고 설계에 명시. 현재 코드상 중장기 후크는 SnapshotScheduler._maybe_tick()과 run_once()를 공유하므로 credential_fetch_enabled=True이면 동일 경로로 처리됨. dcy-027 추가 인스턴스가 별도 run_once() 트리거 주기를 가질 경우 그때 조정.

D6: AlarmEmitter DAY_START_LOAD_FAILED

결정: AlarmEmitter 자체는 변경 없음(코드는 문자열 파라미터로 전달). SnapshotScheduler에서 self._alarm_emitter.publish_alarm("DAY_START_LOAD_FAILED", ...) 직접 호출. ALARM-INVENTORY.md 또는 별도 코드 상수 추가는 BAT-053 범위 외(dcy-033/REQ-100 패턴 재사용).

Module Interface Summary

application/portfolio.py 추가

@dataclass(frozen=True)
class BrokerCredentialPayload:
    account_ref: str
    app_key: str
    app_secret: str
    credential_version: Optional[int] = None

@dataclass(frozen=True)
class TradingContext:
    broker: Optional[BrokerCredentialPayload]
    trading_enabled: bool

# PortfolioHubReadPort에 추가:
def fetch_broker_credential(self) -> TradingContext: ...

adapters/portfolio/hub_read.py 변경

  • whoami() 내부 로직을 _fetch_user_info() -> dict 헬퍼로 추출
  • whoami() = _fetch_user_info()에서 daemon_id 필드 반환 (외부 동작 불변)
  • fetch_broker_credential() = _fetch_user_info()에서 broker, trading_enabled 파싱

adapters/portfolio/stub_read.py 변경

  • trading_context: Optional[TradingContext] = None 생성자 인자 추가
  • fetch_broker_credential() stub 구현

runtime/config.py 변경

  • DaemonInstanceConfig.credential_fetch_enabled: bool = False 추가 + from_toml() 파싱
  • DaemonConfig.credential_fetch_enabled: bool = False 추가
  • DaemonConfig.from_config(): inst.credential_fetch_enabled → DaemonConfig.credential_fetch_enabled 전파

application/snapshot_scheduler.py 변경

  • 생성자: credential_fetch_enabled: bool = False 인자 추가
  • 인스턴스 변수: _cached_credential, _credential_date, _entry_allowed
  • _maybe_fetch_credential() 비공개 메서드 추가
  • run_once(): credential_fetch_enabled=True이면 _maybe_fetch_credential() 호출
  • get_entry_allowed() -> bool 노출

application/intents.py 변경 (IntentService)

  • 생성자: entry_allowed_fn: Optional[Callable[[], bool]] = None 추가
  • _admit_buy(): entry_allowed_fn is not None and not entry_allowed_fn() → reject day_start_load_failed

runtime/daemon.py 변경

  • SnapshotScheduler 생성 시 credential_fetch_enabled=config.credential_fetch_enabled 전달
  • IntentService 생성 시 entry_allowed_fn=self.snapshot_scheduler.get_entry_allowed 전달 → SnapshotScheduler가 먼저 생성되어야 함 (순서 조정 필요 확인)

Layer Compliance

  • domain/ 변경 없음 — 도메인 순수성 불변
  • application/portfolio.py: 새 dataclass(frozen=True) — IO/네트워크 없음 (import sweep 통과)
  • application/snapshot_scheduler.py: portfolio 포트 호출(이미 포트 인터페이스) — 신규 import 없음
  • application/intents.py: callable 주입 — 추가 import 없음
  • adapters/: 구체 구현만 변경
  • runtime/: 조립 레이어 변경