# 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` 추가 ```python @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/`: 조립 레이어 변경