feat(bat-054): 그날-시작 KIS 인증정보 양의 경로 주입 — KISBroker reinitialize (REQ-113 AC-2/AC-4, REQ-115 AC-1)

BAT-053 스캐폴드에서 연기된 양의 경로 완성:
- KISBroker.reinitialize(credential): app_key/app_secret/account_ref 교체 + L1 token 무효화
- SnapshotScheduler에 kis_reinit_callback 주입 지점 추가; 그날-시작 성공 시 callback 호출
- daemon.py에 _build_kis_reinit_callback() 헬퍼 + SnapshotScheduler 조립 수정
- 신규 테스트 12개 추가: 757 passed / 47 skipped (기존 745 대비 +12, 회귀 0)

안전 불변식 유지:
- credential_fetch_enabled=False(기본) 경로 byte-for-byte 보존
- fail-closed 불변식 미파괴 (reinit 실패 시 entry_allowed 유지)
- AMQP wire 계약 변경 없음

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 19:51:48 +09:00
co-authored by Claude Sonnet 4.6
parent a50b5e34a2
commit 6b066e19cb
10 changed files with 733 additions and 1 deletions
@@ -0,0 +1,108 @@
# BAT-054: Design
- Status: confirmed
- Batch: BAT-054
## Architecture Summary
그날-시작 체크포인트에서 fetch된 `BrokerCredentialPayload`를 실제 `KISBroker` 인스턴스에 주입하는 경로를 완성한다.
설계 핵심 두 가지:
1. `KISBroker.reinitialize(credential)` 메서드: `app_key/app_secret/account_ref`를 원자적으로 교체, `base_url/timeout/allow_live/mock`은 유지. L1 token 캐시 무효화 포함.
2. `SnapshotScheduler`에 `kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None` 추가. 그날-시작 성공 시 callback 호출 → KISBroker.reinitialize 트리거.
## Changed Areas
- Docs: docs/batches/bat-054_.../ (이번 batch 문서)
- Modules:
- `trex_core/adapters/broker/kis.py` — `reinitialize()` 메서드 추가
- `trex_core/application/snapshot_scheduler.py` — `kis_reinit_callback` 인자 추가 + 성공 시 호출
- `trex_core/runtime/daemon.py` — `_build_kis_reinit_callback()` + SnapshotScheduler 조립 수정
- Interfaces: 없음 (AMQP wire 계약 변경 없음)
## Key Decisions
### D1: KISBroker.reinitialize() — 인스턴스 변수 직접 교체
**결정**: `KISBroker`에 `reinitialize(credential: BrokerCredentialPayload) -> None` 메서드를 추가한다.
메서드 내에서:
- `self._app_key`, `self._app_secret`, `self._account_ref`를 새 값으로 교체
- L1 token 캐시(`self._token = None`, `self._token_exp = datetime.min`) 무효화
- L2 token cache(Redis, `token_cache.invalidate`가 있으면 호출; 없으면 skip — fallback graceful)
- `base_url/timeout/mock/allow_live`는 **불변** (env에서 온 인프라 설정)
**근거**:
- `KISBroker`는 이미 실행 중인 여러 스레드(execution loop, safety-sell)가 공유하는 인스턴스다. 새 객체로 교체하면 기존 참조가 stale해진다.
- 인스턴스 변수 교체는 CPython GIL 하에서 atomic하며, 진행 중인 HTTP 요청이 완료된 후 다음 _ensure_token() 호출부터 새 자격증명을 사용한다. 그날-시작(07:30 KST)은 장 시작 전이므로 경쟁 조건 위험이 낮다.
- threading.Lock은 추가하지 않는다(reinitialize가 그날-시작 1회만 호출되고 장 시작 전이라 안전). 만약 향후 장중 재초기화가 필요해지면 그때 lock을 추가한다(REQ-115 설계: 그날 고정 캐시로 장중 재fetch 없음).
### D2: SnapshotScheduler.kis_reinit_callback — Optional callable 주입
**결정**: `SnapshotScheduler.__init__`에 `kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None` 추가.
`_maybe_fetch_credential()` 성공 후, 캐시 저장 + INFO 로그 이후에 callback을 호출한다.
```python
# 성공 경로
self._cached_credential = ctx.broker
self._credential_date = today
self._entry_allowed = True
_log.info("DAY_START credential loaded: account_ref=%s ...", ...)
if self._kis_reinit_callback is not None:
try:
self._kis_reinit_callback(ctx.broker)
_log.info("DAY_START KIS broker reinitialized with fetched credential (instance=%s)", ...)
except Exception as exc:
_log.error("DAY_START KIS broker reinitialize failed: %s (instance=%s) — entry remains allowed", ...)
# 재초기화 실패는 entry_allowed를 False로 전환하지 않는다(기존 env 인증정보로 계속 운행)
```
**근거**:
- callback 실패가 entry_allowed를 뒤집지 않는다: 재초기화 실패 시 기존 env 인증정보로 운행 계속 (fail-safe: 이미 기동 중인 브로커가 있으므로).
- 새 인증정보 주입에 성공하면 INFO 로그로 가시화.
- `credential_fetch_enabled=False`(기본)일 때 callback이 None이므로 코드 경로 완전 미진입 → 기존 동작 byte-for-byte 보존.
### D3: daemon.py — callback 조립
**결정**: `daemon.py`에 private helper `_build_kis_reinit_callback(config, broker)` 추가:
```python
def _build_kis_reinit_callback(config, broker):
if config.broker != "kis" or not config.credential_fetch_enabled:
return None
if not hasattr(broker, "reinitialize"):
return None
return broker.reinitialize
```
이를 `SnapshotScheduler` 생성 시 `kis_reinit_callback=_build_kis_reinit_callback(config, self.broker)` 로 전달.
**근거**:
- `broker == 'paper'`이면 callback=None → 기존 경로 보존.
- `credential_fetch_enabled=False`이면 callback=None.
- `KISBroker`가 아닌 브로커가 `reinitialize`를 가지지 않을 경우 graceful skip.
### D4: access token cache (Redis, REQ-016) 무효화
**결정**: `reinitialize()` 내에서:
1. L1 in-memory token 무효화: `self._token = None`, `self._token_exp = datetime.min(UTC)`.
2. L2 Redis: `token_cache`에 `invalidate(account_ref, app_key)` 메서드가 있으면 호출, 없으면 skip. `RedisTokenCache`는 기존 key scheme이 `(account_ref, app_key)` 기반이므로 기존 key가 자연스럽게 miss로 처리된다(새 account_ref/app_key로 조회 시 miss → 새 토큰 발급). 명시적 invalidate API는 추가하지 않는다.
**근거**: 새 `account_ref/app_key`로 L2 조회 시 miss → 새 토큰 발급. 구 key는 TTL로 자연 만료. 인증정보 변경이 1일 1회이므로 수동 무효화 복잡성 불필요.
### D5: REQ-113 AC-4 보강 — break-glass 로그 assert
**결정**: `tests/application/test_snapshot_scheduler.py`에 break-glass 활성화 시 로그에 "break-glass" 키워드가 포함됨을 `caplog`로 검증하는 케이스 추가.
## Edge Cases
- `credential_fetch_enabled=True`이고 `broker='paper'`인 경우: callback=None이므로 재초기화 없음. entry_allowed는 fetch 결과에 따라 설정됨(논리적으로 paper broker에 fetch가 의미 없으나 테스트 가능).
- `reinitialize()` 호출 중 in-flight HTTP 요청: CPython GIL 하에서 변수 할당은 atomic, 다음 `_ensure_token()` 호출부터 새 값 사용. 경쟁 조건 없음(그날-시작 = 장 전).
- `credential_fetch_enabled=True`이고 break-glass: callback 호출 없음(재초기화 없이 env 인증정보 사용) — D2 기술.
## Architecture Impact
- runtime-flows: KISBroker 재초기화 경로 추가 (경미한 갱신)
- project-structure: 변경 없음
## Reference Doc Update Plan
- project-structure.md: none (구조 변경 없음)
- runtime-flows.md: DAY_START 흐름에 KISBroker reinitialize 스텝 추가 설명이 유용하나 이번 batch 필수 아님 — verification에서 판단
@@ -0,0 +1,79 @@
# BAT-054: Implementation
- Status: completed
- Batch: BAT-054
## Plan Summary
BAT-053에서 스캐폴드로 처리된 양의 경로를 완성:
1. `KISBroker.reinitialize(credential)` 메서드 추가 — app_key/app_secret/account_ref 교체 + L1 token 무효화
2. `SnapshotScheduler`에 `kis_reinit_callback` 주입 지점 추가 — 그날-시작 성공 시 callback 호출
3. `daemon.py`에 `_build_kis_reinit_callback()` 헬퍼 + SnapshotScheduler 조립 수정
4. 신규 AC 테스트 12개 추가 (REQ-113 AC-2/AC-4 보강, REQ-115 AC-1)
## Changed Files
| 파일 | 변경 유형 | 관련 REQ |
| --- | --- | --- |
| `trex_core/adapters/broker/kis.py` | 확장 (reinitialize 메서드 추가) | REQ-113 AC-2, REQ-115 AC-1 |
| `trex_core/application/snapshot_scheduler.py` | 확장 (kis_reinit_callback 인자 + 성공 경로 callback 호출) | REQ-113 AC-2, REQ-115 AC-1 |
| `trex_core/runtime/daemon.py` | 확장 (_build_kis_reinit_callback 헬퍼 + SnapshotScheduler 조립) | REQ-113 AC-2, REQ-115 AC-1 |
| `tests/application/test_snapshot_scheduler.py` | 확장 (BAT-054 테스트 12개 추가) | REQ-113 AC-2/AC-4, REQ-115 AC-1 |
## Execution Log
1. `KISBroker.reinitialize()` 구현:
- `self._account_ref/app_key/app_secret` 교체
- `self._token = None`, `self._token_exp = datetime.min(UTC)` L1 무효화
- `base_url/timeout/mock/allow_live` 불변 (env-sourced 인프라 설정)
- docstring에 L2 Redis token cache 처리 방침(새 key miss → 자연 재발급) 기술
2. `SnapshotScheduler.__init__` 확장:
- `kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None` 추가
- `self._kis_reinit_callback` 저장
3. `SnapshotScheduler._maybe_fetch_credential()` 성공 경로 확장:
- 기존 INFO 로그 뒤에 callback 호출 블록 추가
- callback 실패 시 ERROR 로그 + entry_allowed 유지(fail-safe)
- break-glass 경로: callback 호출 없음 (env 인증정보 사용)
4. `daemon.py` 확장:
- `_build_kis_reinit_callback(config, broker)` 헬퍼 추가
- `config.broker != 'kis'` → None
- `not config.credential_fetch_enabled` → None
- `broker.reinitialize` 없음 → None
- 조건 충족 시 `broker.reinitialize` callable 반환
- `SnapshotScheduler` 생성 시 `kis_reinit_callback=_build_kis_reinit_callback(config, self.broker)` 전달
5. 테스트 추가 (12개):
- `test_kis_reinit_callback_called_on_success` — AC-2: fetch 성공 시 callback 호출
- `test_kis_reinit_callback_not_called_when_fail_closed` — AC-2: fail-closed 시 callback 미호출
- `test_kis_reinit_callback_not_called_when_disabled` — AC-1 회귀: opt-in off 시 callback 미호출
- `test_kis_reinit_callback_failure_does_not_flip_entry_allowed` — 설계 안전망: reinit 실패 시 entry_allowed 유지
- `test_kis_reinit_callback_cached_for_day_not_called_twice` — REQ-115 AC-3: 그날 두 번째 run_once에서 재호출 없음
- `test_kis_reinit_callback_none_when_break_glass` — AC-3+AC-2: break-glass 시 callback 없음
- `test_break_glass_log_contains_break_glass_keyword` — REQ-113 AC-4 보강: break-glass 로그 content
- `test_kis_broker_reinitialize_updates_credentials` — REQ-115 AC-1: 인증정보 교체 + L1 무효화
- `test_kis_broker_reinitialize_preserves_base_url_and_flags` — REQ-115 AC-1: 인프라 설정 불변
- `test_build_kis_reinit_callback_returns_none_for_paper` — AC-2: paper broker → None
- `test_build_kis_reinit_callback_returns_none_when_disabled` — AC-1: fetch disabled → None
- `test_build_kis_reinit_callback_returns_callable_for_kis` — AC-2/AC-1: kis+enabled → callable
## Validation
```
cd /home/genichin/workspace/trex-lead/repos/trex-core/.claude/worktrees/agent-a0d6ff73d43f2e96b
python3 -m pytest tests/application/test_snapshot_scheduler.py -v
```
결과: **29 passed** (BAT-053 기존 17 + BAT-054 신규 12)
```
python3 -m pytest tests/ -q
```
결과: **757 passed, 47 skipped** (기존 베이스라인 745 대비 +12, 회귀 0)
## Remaining Risks
- R1 (Low): 실 portfolio-hub 없이 stub 기반으로만 검증. 실 `/v1/user_info` 응답 스키마 호환은 portfolio-hub dcy-010 배포 후 통합 테스트에서 확인 필요.
- R2 (Low): KISBroker.reinitialize()는 그날-시작(장 전) 1회 호출 설계이므로 in-flight HTTP 경쟁 조건 위험이 낮으나, 장중 reinitialize가 필요한 시나리오가 생기면 threading.Lock 추가 검토 필요.
- R3 (Low): L2 Redis token cache(REQ-016) 명시적 무효화 없음 — 새 (account_ref, app_key) key miss로 자연 재발급 처리. 동일 account_ref/app_key인데 app_secret만 바뀐 경우 구 토큰이 TTL 만료 전까지 유효할 수 있음. 운영상 계좌 인증정보 전체가 함께 교체되는 구조라 실용적으로 문제 없음.
@@ -0,0 +1,35 @@
# BAT-054: 그날-시작 KIS 인증정보 — 양의 경로 주입 (KISBroker 재조립)
- Status: in-progress
- Profile: standard
- Discovery: dcy-036
- Created: 2026-06-28
## Included REQ
- REQ-113: KISConfig 소스 우선순위 — portfolio-hub fetch 1차 + env break-glass + opt-in (AC-2, AC-4, AC-9 미구현 부분)
- REQ-115: 그날-시작 장중 무중단 보장 — 그날 고정 인증정보 캐시 + portfolio-hub 장애 비중단 (AC-1 미구현 부분)
## Excluded REQ
- REQ-112: Implemented (BAT-053)
- REQ-114: Implemented (BAT-053)
- REQ-113 AC-1/3/5/6/7: Implemented (BAT-053)
- REQ-115 AC-2/3/4/5/6: Implemented (BAT-053)
## Documents
- [planning.md](./planning.md)
- [design.md](./design.md)
- [implementation.md](./implementation.md)
- [verification.md](./verification.md)
## Notes
- BAT-053 (PR #34 머지, commit 01655ab)에서 스텁/스캐폴드로 처리된 **양의 경로 미구현 부분**을 이번 batch에서 완성한다.
- 핵심 잔여 작업:
- REQ-113 AC-2: fetch 성공 시 `BrokerCredentialPayload`로 `KISConfig` 재조립 → `KISBroker` 재초기화
- REQ-115 AC-1: 장중 portfolio-hub 장애 후에도 캐시된 인증정보로 KIS 주문 처리 지속
- REQ-113 AC-4 보강: break-glass 로그 content assert (BAT-053 Notes: "AC-4 break-glass 로그 내용 assert 보강 후속")
- AMQP wire 계약 변경 없음 — trex-orb-v1/trex-tsl-v1 파급 없음.
- 실거래(real money) 주입 경로 — blast radius 크다. 팀장 검수 + QC + 사장님 머지 필요.
@@ -0,0 +1,57 @@
# BAT-054: Planning
- Status: confirmed
- Batch: BAT-054
- Profile: standard
## Included REQ
- REQ-113 (Approved, 미충족 AC: AC-2 fetch→KISConfig 재조립, AC-4 break-glass 로그 assert)
- REQ-115 (Approved, 미충족 AC: AC-1 캐시 인증정보로 KIS 주문 지속)
## Scope Summary
BAT-053에서 스캐폴드로 처리된 "양의 경로(positive path)"를 완성한다.
- 그날-시작 체크포인트에서 fetch 성공 시 `BrokerCredentialPayload`로 `KISConfig`의 `app_key/app_secret/account_ref`를 재조립하고 이를 실제 `KISBroker` 인스턴스에 주입한다.
- 재조립 후 `KISBroker`는 그날 캐시된 인증정보로 장중 KIS API 호출(주문·체결 조회·토큰 갱신)을 처리한다.
- portfolio-hub 장중 장애는 이미 재조립된 KISBroker 운행에 영향을 주지 않는다.
- break-glass 로그 내용 assert 테스트를 추가한다(AC-4 보강).
## Task Breakdown
### T1: REQ-113 AC-2 — KISBroker 재조립 callback 설계 + 구현
- `SnapshotScheduler`에 `kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None` 추가
- 그날-시작 성공 시 `_maybe_fetch_credential()`에서 callback 호출 → KISBroker 재초기화
- `daemon.py`의 `_build_broker()`에서 KIS 브로커면 callback을 조립하여 주입
### T2: REQ-115 AC-1 — KISBroker 런타임 교체 메서드
- `KISBroker`(또는 daemon.py)에 `reinitialize(credential: BrokerCredentialPayload)` 같은 메서드 제공
- 재초기화는 `app_key/app_secret/account_ref`만 교체; `base_url/timeout/allow_live`는 env 원본 유지
- KIS access token 캐시(Redis, REQ-016)는 `app_key` 변경 시 무효화 필요
### T3: REQ-113 AC-4 보강 — break-glass 로그 assert 테스트
- `tests/runtime/test_config.py` 또는 `tests/application/test_snapshot_scheduler.py`에 AC-4 로그 content 검증 케이스 추가
## Design Gate
- Design Required Before Implementation: yes
- Reason: KISBroker 재초기화 방법(메서드 추가 vs callback 교체 vs wrapper) + 재초기화 시 access token 캐시 무효화 정책이 구현 전에 명시되어야 한다. 기존 `KISBroker` 코드를 확인 후 결정.
## Dependencies
- Internal:
- BAT-053 완료(REQ-112 `BrokerCredentialPayload`/`TradingContext` 구현, REQ-114 fail-closed 스캐폴드)
- `adapters/broker/kis.py` (KISBroker) — reinitialize 진입점
- `runtime/daemon.py` (`_build_broker`, SnapshotScheduler 조립)
- External:
- 없음 (portfolio-hub 실 연동은 스텁 기반 테스트로 커버)
## Risks
- R1 (High): KISBroker 재초기화 중 진행 중인 주문 경쟁 조건 — broker 레벨 lock 또는 재초기화 안전 구간 확인 필요
- R2 (Medium): access token 캐시(Redis, REQ-016) 무효화 누락 → 구 account_ref 토큰이 신규 인증정보와 충돌
- R3 (Medium): `daemon.py`에서 callback 조립 시 `DaemonConfig.broker == 'kis'` 여부 확인 — paper broker면 callback 불필요
- R4 (Low): 테스트에서 실제 KIS API를 호출하지 않도록 mock/stub 격리 유지 — KISBroker.reinitialize()는 stub BrokerPort로 테스트
@@ -0,0 +1,101 @@
# BAT-054: Verification
- Status: draft
- Batch: BAT-054
## Acceptance Mapping
### REQ-113 — KISConfig 소스 우선순위 (미충족 AC)
**AC-2**: Given TOML에 `credential_fetch_enabled = true`이고 portfolio-hub fetch가 유효한 `BrokerCredentialPayload`를 반환, When 데몬 기동(그날-시작), Then `KISConfig.app_key`, `KISConfig.app_secret`, `KISConfig.account_ref`가 fetch 결과로 구성된다.
- Evidence: `test_kis_reinit_callback_called_on_success` — fetch 성공 시 callback이 `BrokerCredentialPayload(account_ref="stub-account", app_key="stub-app-key", app_secret="stub-app-secret")`으로 호출됨 확인. PASSED.
- Evidence: `test_kis_broker_reinitialize_updates_credentials` — `KISBroker.reinitialize(cred)` 호출 후 `broker._account_ref/app_key/app_secret`이 cred 값으로 교체됨 + L1 token 무효화 확인. PASSED.
- Evidence: `test_build_kis_reinit_callback_returns_callable_for_kis` — broker=kis + fetch enabled 시 callback callable 반환 + 실제 broker.reinitialize 동작 확인. PASSED.
- Note: 실 KISBroker를 KIS API에 연결한 통합 테스트는 portfolio-hub dcy-010 배포 후 가능. 현재 stub 기반으로 핵심 경로 검증.
**AC-4**: Given break-glass 활성화 상태, When 데몬 기동 로그 확인, Then break-glass 활성화 사실이 INFO로 기록된다.
- Evidence (보강): `test_break_glass_log_contains_break_glass_keyword` — `TREX_KIS_BROKER_BREAK_GLASS=true` 시 로그에 "break-glass" 키워드 포함 확인. PASSED.
- Note: BAT-053에서 break-glass 경로는 구현되었으나 로그 content assert가 없었음. 이번 batch에서 추가.
**REQ-113 AC-1, AC-3, AC-5, AC-6, AC-7**: BAT-053 Implemented. 회귀 없음 (전체 스위트 757 passed, 회귀 0).
### REQ-115 — 그날-시작 장중 무중단 (미충족 AC)
**AC-1**: Given 그날-시작 로드 성공(`credential_fetch_enabled = true`) 후 portfolio-hub를 내려(장중 장애 주입), When BuyIntent 또는 SellIntent를 처리, Then KIS 브로커 어댑터가 캐시된 인증정보로 주문을 처리하고 `PortfolioHubUnavailableError`로 인해 주문이 차단되지 않는다.
- Evidence: `test_kis_reinit_callback_called_on_success` — 그날-시작에 callback(=broker.reinitialize)이 호출됨으로써 KISBroker가 fetched 인증정보로 업데이트됨. PASSED.
- Evidence: `test_kis_broker_reinitialize_updates_credentials` — reinitialize 후 broker가 새 인증정보 보유, 이후 KIS API 호출은 broker 자체 인증정보로 수행 (portfolio-hub 불필요). PASSED.
- Evidence: `test_kis_reinit_callback_failure_does_not_flip_entry_allowed` — reinit callback 실패 시에도 entry_allowed 유지 (기존 env 인증정보로 계속 운행). PASSED.
- Note: 장중 portfolio-hub 다운 상태에서 실 KISBroker 주문 처리를 끝까지 검증하려면 통합 테스트 환경이 필요. 현재 stub 기반으로 핵심 속성(reinitialize 완료 후 broker가 portfolio-hub와 분리됨) 검증.
**AC-3**: Given 그날 로드 성공 후 portfolio-hub가 잠시 다운되었다가 복구, When 그날 중 재fetch 트리거 없음, Then 그날 인증정보는 재load 없이 최초 fetch 값 그대로 유지된다.
- Evidence: `test_kis_reinit_callback_cached_for_day_not_called_twice` — 동일 날 두 번째 run_once에서 callback 재호출 없음(캐시 hit). PASSED.
- Evidence: `test_successful_credential_cached_for_day` (BAT-053) — fetch count = 1 유지. PASSED.
**REQ-115 AC-2, AC-4, AC-5, AC-6**: BAT-053 Implemented. 회귀 없음.
## Evidence
### Test Results
```
pytest tests/ -q
757 passed, 47 skipped in 1.59s
```
기존 베이스라인: 745 passed, 47 skipped (BAT-053 완료 시점)
이번 batch 신규 테스트: +12 (29 total in test_snapshot_scheduler.py)
회귀: 0
### 신규 테스트 목록 (12개)
| 테스트명 | 검증 AC | 결과 |
| --- | --- | --- |
| `test_kis_reinit_callback_called_on_success` | REQ-113 AC-2, REQ-115 AC-1 | PASS |
| `test_kis_reinit_callback_not_called_when_fail_closed` | REQ-113 AC-2 | PASS |
| `test_kis_reinit_callback_not_called_when_disabled` | REQ-113 AC-1 회귀 | PASS |
| `test_kis_reinit_callback_failure_does_not_flip_entry_allowed` | 설계 안전망 | PASS |
| `test_kis_reinit_callback_cached_for_day_not_called_twice` | REQ-115 AC-3 | PASS |
| `test_kis_reinit_callback_none_when_break_glass` | REQ-113 AC-3 + AC-2 | PASS |
| `test_break_glass_log_contains_break_glass_keyword` | REQ-113 AC-4 보강 | PASS |
| `test_kis_broker_reinitialize_updates_credentials` | REQ-115 AC-1, REQ-113 AC-2 | PASS |
| `test_kis_broker_reinitialize_preserves_base_url_and_flags` | REQ-115 AC-1 | PASS |
| `test_build_kis_reinit_callback_returns_none_for_paper` | REQ-113 AC-2 | PASS |
| `test_build_kis_reinit_callback_returns_none_when_disabled` | REQ-113 AC-1 | PASS |
| `test_build_kis_reinit_callback_returns_callable_for_kis` | REQ-113 AC-2, REQ-115 AC-1 | PASS |
### fail-closed 불변식 회귀 확인
BAT-053 구현 테스트 17개 모두 PASS:
- `test_run_once_no_credential_fetch_when_disabled` — opt-in off 회귀
- `test_run_once_portfolio_hub_unavailable_sets_fail_closed` — AC-1
- `test_run_once_broker_null_sets_fail_closed` — AC-2
- `test_run_once_trading_disabled_sets_fail_closed` — AC-3
- `test_intent_service_buy_rejected_when_entry_not_allowed` — AC-4
- `test_intent_service_sell_passes_when_entry_not_allowed` — AC-5
- 기타 기존 12개 PASS
## Result
- Ready for Release: false
- Blocking Issues:
- 없음 (코드/테스트 레벨 구현 및 검증 완료)
- Missing evidence (비차단):
- REQ-115 AC-1 통합 검증: 실 portfolio-hub + 실 KIS mock(openapivts) 환경에서 그날-시작 fetch → KISBroker reinitialize → 장중 주문 처리까지의 end-to-end 검증은 portfolio-hub dcy-010 배포 후 가능. 현재 stub 기반 단위 검증으로 충분하다고 판단하나, 팀장 확인 필요.
- REQ-113 AC-9 (break-glass 전용 경보): BAT-053 Notes에 기재된 "AC-9 전용 테스트 보강"은 REQ-113에 AC-9 항목이 없음을 확인 (AC는 1~7까지). BAT-053 Notes의 "AC-9"는 REQ-114 AC-9(그날 고정 상태)를 지칭하며, 이는 `test_entry_allowed_fixed_for_day_no_refetch`(PASS)로 커버됨. 별도 break-glass 경보 요구사항 없음 확인.
### 팀장 검수 요청 사항
1. REQ-115 AC-1 stub 기반 검증으로 `confirm-batch-verification` 진행 가능한지 판단 (portfolio-hub dcy-010 배포 전 단계에서의 기준)
2. REQ-113 Status 갱신 권한: 이번 batch 완료로 AC-2/AC-4 충족 → Status `Approved` → `Implemented` 갱신 여부
### QC 체크리스트 (qc-reviewer용)
- [ ] BAT-054 신규 테스트 12개 독립 실행 PASS 확인
- [ ] fail-closed 불변식 미파괴 확인 (opt-in=False 경로에서 gateway 비활성, `entry_allowed_fn=None`)
- [ ] app_secret 평문 로그 미노출 확인 (기존 AC-7 테스트 PASS, reinitialize 로그 없음)
- [ ] domain 순수성 불변 확인 (`trex_core/domain/` 변경 없음)
- [ ] AMQP wire 계약 변경 없음 확인 (trex-orb-v1/trex-tsl-v1 영향 없음)
+2 -1
View File
@@ -60,4 +60,5 @@
| BAT-050 | standard | release-candidate | REQ-102, REQ-103, REQ-104, REQ-107, REQ-108 | dcy-034 (core-A) | [bat-050](./bat-050_20260626_reconcile-warning-blocking-gate-core-a/) | | BAT-050 | standard | release-candidate | REQ-102, REQ-103, REQ-104, REQ-107, REQ-108 | dcy-034 (core-A) | [bat-050](./bat-050_20260626_reconcile-warning-blocking-gate-core-a/) |
| BAT-051 | standard | release-candidate | REQ-105, REQ-106 | dcy-034 (core-B) | [bat-051](./bat-051_20260626_sync-verify-cli-cold-start-core-b/) | | BAT-051 | standard | release-candidate | REQ-105, REQ-106 | dcy-034 (core-B) | [bat-051](./bat-051_20260626_sync-verify-cli-cold-start-core-b/) |
| BAT-052 | standard | release-candidate | REQ-109, REQ-110, REQ-111 | dcy-035 | [bat-052](./bat-052_20260626_wac-realized-pnl-report/) | | BAT-052 | standard | release-candidate | REQ-109, REQ-110, REQ-111 | dcy-035 | [bat-052](./bat-052_20260626_wac-realized-pnl-report/) |
| BAT-053 | standard | in-progress | REQ-112, REQ-113, REQ-114, REQ-115 | dcy-036 | [bat-053](./bat-053_20260628_daystart-credential-fetch-failsafe/) | | BAT-053 | standard | release-candidate | REQ-112, REQ-113, REQ-114, REQ-115 | dcy-036 | [bat-053](./bat-053_20260628_daystart-credential-fetch-failsafe/) |
| BAT-054 | standard | in-progress | REQ-113 (AC-2/AC-4 보강), REQ-115 (AC-1) | dcy-036 | [bat-054](./bat-054_20260628_daystart-credential-kis-inject/) |
@@ -399,3 +399,273 @@ def test_intent_service_buy_passes_when_entry_allowed_fn_none():
BuyIntent(intent_id="b2", portfolio_id="PF-2", qty=5, entry_price=500) BuyIntent(intent_id="b2", portfolio_id="PF-2", qty=5, entry_price=500)
) )
assert decision.admitted assert decision.admitted
# ---------------------------------------------------------------------------
# BAT-054: REQ-113 AC-2 / REQ-115 AC-1 — KISBroker reinit callback
# ---------------------------------------------------------------------------
def test_kis_reinit_callback_called_on_success():
"""REQ-113 AC-2 / REQ-115 AC-1: fetch 성공 시 kis_reinit_callback 호출됨."""
from trex_core.application.portfolio import BrokerCredentialPayload
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub() # valid broker + trading_enabled=True
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
assert sched.get_entry_allowed() is True
# callback should have been called exactly once with the fetched credential
assert len(reinit_calls) == 1
assert isinstance(reinit_calls[0], BrokerCredentialPayload)
assert reinit_calls[0].account_ref == "stub-account"
assert reinit_calls[0].app_key == "stub-app-key"
# app_secret should be passed (for KISBroker reinit) but NOT logged
assert reinit_calls[0].app_secret == "stub-app-secret"
def test_kis_reinit_callback_not_called_when_fail_closed():
"""REQ-113 AC-2: fetch 실패(fail-closed) 시 kis_reinit_callback 호출 안 됨."""
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub(fetch_raises=True)
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
assert sched.get_entry_allowed() is False
# No reinit on failure
assert len(reinit_calls) == 0
def test_kis_reinit_callback_not_called_when_disabled():
"""REQ-113 AC-1 / REQ-115 회귀: credential_fetch_enabled=False → callback 호출 없음."""
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=False, # opt-in off (default)
kis_reinit_callback=fake_reinit, # callback present but should not fire
)
sched.run_once()
# no fetch, no reinit
assert len(reinit_calls) == 0
def test_kis_reinit_callback_failure_does_not_flip_entry_allowed():
"""REQ-113 AC-2 설계 D2: reinit callback 실패 시 entry_allowed를 False로 전환 안 함."""
def failing_reinit(credential):
raise RuntimeError("simulated KISBroker reinit failure")
read = _make_stub() # valid broker + trading_enabled=True
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=failing_reinit,
)
sched.run_once()
# reinit failure must NOT cause fail-closed
assert sched.get_entry_allowed() is True
def test_kis_reinit_callback_cached_for_day_not_called_twice():
"""REQ-115 AC-3 + REQ-113 AC-2: 그날 두 번째 run_once에서 callback 재호출 안 됨."""
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
assert len(reinit_calls) == 1
# Second run_once same day: cache hit, no re-fetch, no re-reinit
sched.run_once()
assert len(reinit_calls) == 1 # still 1
def test_kis_reinit_callback_none_when_break_glass(monkeypatch):
"""REQ-113 AC-3 + AC-2: break-glass 시 callback 호출 없음 (env 경로)."""
monkeypatch.setenv("TREX_KIS_BROKER_BREAK_GLASS", "true")
reinit_calls = []
def fake_reinit(credential):
reinit_calls.append(credential)
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
kis_reinit_callback=fake_reinit,
)
sched.run_once()
# break-glass: env-only path, no fetch, no reinit
assert len(reinit_calls) == 0
assert sched.get_entry_allowed() is True
# ---------------------------------------------------------------------------
# BAT-054: REQ-113 AC-4 보강 — break-glass 로그 content assert
# ---------------------------------------------------------------------------
def test_break_glass_log_contains_break_glass_keyword(monkeypatch, caplog):
"""REQ-113 AC-4 보강: break-glass 활성화 시 로그에 'break-glass' 키워드 포함."""
import logging
monkeypatch.setenv("TREX_KIS_BROKER_BREAK_GLASS", "true")
read = _make_stub()
store = SnapshotStore()
sched = SnapshotScheduler(
read_port=read, store=store, broker=PaperBroker(),
credential_fetch_enabled=True,
)
with caplog.at_level(logging.INFO):
sched.run_once()
log_lower = caplog.text.lower()
assert "break-glass" in log_lower or "break_glass" in log_lower
# ---------------------------------------------------------------------------
# BAT-054: REQ-115 AC-1 — KISBroker.reinitialize() 단위 테스트
# ---------------------------------------------------------------------------
def test_kis_broker_reinitialize_updates_credentials():
"""REQ-115 AC-1 / REQ-113 AC-2: KISBroker.reinitialize()가 인증정보를 교체함."""
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.portfolio import BrokerCredentialPayload
broker = KISBroker(
"old-account",
app_key="old-key",
app_secret="old-secret",
base_url="https://openapivts.koreainvestment.com:29443",
allow_live=False,
)
# Simulate a cached token
broker._token = "old-token"
new_cred = BrokerCredentialPayload(
account_ref="new-account-01234567-01",
app_key="new-app-key",
app_secret="new-app-secret",
credential_version=2,
)
broker.reinitialize(new_cred)
assert broker._account_ref == "new-account-01234567-01"
assert broker._app_key == "new-app-key"
assert broker._app_secret == "new-app-secret"
# L1 token should be cleared
assert broker._token is None
def test_kis_broker_reinitialize_preserves_base_url_and_flags():
"""REQ-115 AC-1: reinitialize()가 base_url/allow_live/_mock를 보존함."""
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.portfolio import BrokerCredentialPayload
broker = KISBroker(
"old-account",
app_key="old-key",
app_secret="old-secret",
base_url="https://openapivts.koreainvestment.com:29443",
allow_live=False,
)
original_base_url = broker._base_url
original_mock = broker._mock
original_timeout = broker._timeout
new_cred = BrokerCredentialPayload(
account_ref="new-account",
app_key="new-key",
app_secret="new-secret",
)
broker.reinitialize(new_cred)
assert broker._base_url == original_base_url
assert broker._mock == original_mock
assert broker._timeout == original_timeout
# ---------------------------------------------------------------------------
# BAT-054: daemon.py _build_kis_reinit_callback 단위 테스트
# ---------------------------------------------------------------------------
def test_build_kis_reinit_callback_returns_none_for_paper():
"""REQ-113 AC-2: broker=paper이면 callback=None."""
from trex_core.adapters.broker.paper import PaperBroker as PB
from trex_core.runtime.daemon import _build_kis_reinit_callback
from trex_core.runtime.config import DaemonConfig
# minimal config: paper broker, credential_fetch_enabled=True
cfg = DaemonConfig(dsn="x", broker="paper", execution_interval_sec=1.0,
credential_fetch_enabled=True)
result = _build_kis_reinit_callback(cfg, PB())
assert result is None
def test_build_kis_reinit_callback_returns_none_when_disabled():
"""REQ-113 AC-1: credential_fetch_enabled=False이면 callback=None."""
from trex_core.adapters.broker.paper import PaperBroker as PB
from trex_core.runtime.daemon import _build_kis_reinit_callback
from trex_core.runtime.config import DaemonConfig
cfg = DaemonConfig(dsn="x", broker="kis", execution_interval_sec=1.0,
credential_fetch_enabled=False)
result = _build_kis_reinit_callback(cfg, PB())
assert result is None
def test_build_kis_reinit_callback_returns_callable_for_kis():
"""REQ-113 AC-2 / REQ-115 AC-1: broker=kis + fetch enabled → callable 반환 + 실제 reinitialize 호출됨."""
from trex_core.adapters.broker.kis import KISBroker
from trex_core.application.portfolio import BrokerCredentialPayload
from trex_core.runtime.daemon import _build_kis_reinit_callback
from trex_core.runtime.config import DaemonConfig
cfg = DaemonConfig(dsn="x", broker="kis", execution_interval_sec=1.0,
credential_fetch_enabled=True)
broker = KISBroker(
"acct",
app_key="k",
app_secret="s",
base_url="https://openapivts.koreainvestment.com:29443",
allow_live=False,
)
result = _build_kis_reinit_callback(cfg, broker)
assert callable(result)
# verify calling the callback actually updates the broker
new_cred = BrokerCredentialPayload(
account_ref="new-acct",
app_key="new-k",
app_secret="new-s",
)
result(new_cred)
assert broker._account_ref == "new-acct"
assert broker._app_key == "new-k"
+32
View File
@@ -69,6 +69,38 @@ class KISBroker:
def name(self) -> str: def name(self) -> str:
return "kis-mock" if self._mock else "kis-live" return "kis-mock" if self._mock else "kis-live"
# --- credential hot-swap (dcy-036 / REQ-113 AC-2 / REQ-115 AC-1) ----------
def reinitialize(self, credential) -> None:
"""Replace app_key/app_secret/account_ref with fetched day-start credential.
Called once at the day-start checkpoint (07:30 KST) after a successful
portfolio-hub fetch (REQ-113 AC-2 / REQ-115 AC-1). base_url / timeout /
allow_live / _mock are env-sourced and remain unchanged.
Thread-safety: CPython GIL makes attribute assignment atomic. This is
safe because reinitialize() is called at day-start (before trading) and
the "day-scoped fixed cache" principle (REQ-115) ensures no intraday
re-fetch. If intraday reinitialize is ever needed a lock should be
added at that time.
Access-token invalidation:
- L1 in-memory token is cleared so the next _ensure_token() call
issues a fresh token with the new credentials.
- L2 Redis cache: the new (account_ref, app_key) key will miss on
first lookup, triggering a fresh token issue. The old key expires
via TTL — no explicit delete needed for 1x-daily rotation.
Args:
credential: BrokerCredentialPayload (from trex_core.application.portfolio)
providing account_ref, app_key, app_secret.
"""
self._account_ref = credential.account_ref
self._app_key = credential.app_key
self._app_secret = credential.app_secret
# Invalidate L1 in-memory token so next call re-issues.
self._token = None
self._token_exp = datetime.min.replace(tzinfo=timezone.utc)
# --- auth --- # --- auth ---
def _ensure_token(self) -> str: def _ensure_token(self) -> str:
# L1: in-memory # L1: in-memory
@@ -50,6 +50,7 @@ class SnapshotScheduler:
alarm_emitter: Optional[AlarmEmitter] = None, alarm_emitter: Optional[AlarmEmitter] = None,
warn_store: Optional[ReconcileWarningStore] = None, warn_store: Optional[ReconcileWarningStore] = None,
credential_fetch_enabled: bool = False, credential_fetch_enabled: bool = False,
kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None,
) -> None: ) -> None:
self._read = read_port self._read = read_port
self._store = store self._store = store
@@ -72,6 +73,10 @@ class SnapshotScheduler:
# dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection. # dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection.
# credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch. # credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch.
self._credential_fetch_enabled: bool = credential_fetch_enabled self._credential_fetch_enabled: bool = credential_fetch_enabled
# dcy-036 / REQ-113 AC-2 / REQ-115 AC-1: optional callback to reinitialize
# KISBroker with the fetched day-start credential. None = no reinit (paper
# broker or credential_fetch_enabled=False). Set by daemon.py assembly.
self._kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = kis_reinit_callback
# REQ-113: detect break-glass at init time (env var set by operator). # REQ-113: detect break-glass at init time (env var set by operator).
self._break_glass: bool = ( self._break_glass: bool = (
os.environ.get("TREX_KIS_BROKER_BREAK_GLASS", "").lower() os.environ.get("TREX_KIS_BROKER_BREAK_GLASS", "").lower()
@@ -206,6 +211,26 @@ class SnapshotScheduler:
ctx.broker.credential_version, ctx.broker.credential_version,
self._instance_key, self._instance_key,
) )
# dcy-036 / REQ-113 AC-2 / REQ-115 AC-1: reinitialize KISBroker with
# fetched credential so intraday KIS API calls use the day-start creds.
# Failure does NOT flip entry_allowed — the broker runs on existing env
# credentials until next checkpoint (graceful degrade, not fail-closed).
if self._kis_reinit_callback is not None:
try:
self._kis_reinit_callback(ctx.broker)
_log.info(
"DAY_START KISBroker reinitialized with fetched credential "
"(account_ref=%s, instance=%s)",
ctx.broker.account_ref,
self._instance_key,
)
except Exception as exc: # noqa: BLE001
_log.error(
"DAY_START KISBroker reinitialize failed: %s (instance=%s) "
"— trading continues with existing env credential",
exc.__class__.__name__,
self._instance_key,
)
def _emit_day_start_alarm(self, *, reason: str) -> None: def _emit_day_start_alarm(self, *, reason: str) -> None:
"""Publish CRITICAL DAY_START_LOAD_FAILED alarm (REQ-114).""" """Publish CRITICAL DAY_START_LOAD_FAILED alarm (REQ-114)."""
+24
View File
@@ -122,6 +122,27 @@ def _build_transport(config: DaemonConfig) -> MessagingTransport:
return RabbitMQTransport(mq.url, timeout=mq.timeout) return RabbitMQTransport(mq.url, timeout=mq.timeout)
def _build_kis_reinit_callback(config: DaemonConfig, broker: BrokerPort):
"""Build a KISBroker reinitialize callback for the day-start credential hook.
Returns broker.reinitialize if all conditions are met:
- config.broker == 'kis' (not paper)
- config.credential_fetch_enabled == True (opt-in)
- broker has a reinitialize() method (KISBroker)
Returns None otherwise so SnapshotScheduler skips reinit (backward compat).
dcy-036 / REQ-113 AC-2 / REQ-115 AC-1.
"""
if config.broker != "kis":
return None
if not config.credential_fetch_enabled:
return None
reinit = getattr(broker, "reinitialize", None)
if reinit is None or not callable(reinit):
return None
return reinit
def _build_portfolio_read(config: DaemonConfig) -> PortfolioHubReadPort: def _build_portfolio_read(config: DaemonConfig) -> PortfolioHubReadPort:
"""portfolio-hub HTTP read adapter when configured; otherwise stub (REQ-027).""" """portfolio-hub HTTP read adapter when configured; otherwise stub (REQ-027)."""
pf = config.portfolio_hub pf = config.portfolio_hub
@@ -216,6 +237,9 @@ class Daemon:
alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100 alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100
warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning
credential_fetch_enabled=config.credential_fetch_enabled, # dcy-036 / REQ-113 credential_fetch_enabled=config.credential_fetch_enabled, # dcy-036 / REQ-113
# dcy-036 / REQ-113 AC-2 / REQ-115 AC-1: KISBroker reinit callback.
# None when broker!=kis or credential_fetch_enabled=False (backward compat).
kis_reinit_callback=_build_kis_reinit_callback(config, self.broker),
) )
# dcy-036 / REQ-114: wire entry_allowed_fn after snapshot_scheduler is created. # 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. # When credential_fetch_enabled=False (default), entry_allowed_fn=None = no gate.