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:
@@ -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 영향 없음)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user