diff --git a/docs/batches/bat-054_20260628_daystart-credential-kis-inject/design.md b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/design.md new file mode 100644 index 0000000..6b934dc --- /dev/null +++ b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/design.md @@ -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에서 판단 diff --git a/docs/batches/bat-054_20260628_daystart-credential-kis-inject/implementation.md b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/implementation.md new file mode 100644 index 0000000..5fdc9c0 --- /dev/null +++ b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/implementation.md @@ -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 만료 전까지 유효할 수 있음. 운영상 계좌 인증정보 전체가 함께 교체되는 구조라 실용적으로 문제 없음. diff --git a/docs/batches/bat-054_20260628_daystart-credential-kis-inject/index.md b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/index.md new file mode 100644 index 0000000..9fa238e --- /dev/null +++ b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/index.md @@ -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 + 사장님 머지 필요. diff --git a/docs/batches/bat-054_20260628_daystart-credential-kis-inject/planning.md b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/planning.md new file mode 100644 index 0000000..328d91a --- /dev/null +++ b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/planning.md @@ -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로 테스트 diff --git a/docs/batches/bat-054_20260628_daystart-credential-kis-inject/verification.md b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/verification.md new file mode 100644 index 0000000..89ee4c1 --- /dev/null +++ b/docs/batches/bat-054_20260628_daystart-credential-kis-inject/verification.md @@ -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 영향 없음) diff --git a/docs/batches/index.md b/docs/batches/index.md index 9dda6d6..02a961d 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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-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-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/) | diff --git a/tests/application/test_snapshot_scheduler.py b/tests/application/test_snapshot_scheduler.py index e863b39..dc23ad2 100644 --- a/tests/application/test_snapshot_scheduler.py +++ b/tests/application/test_snapshot_scheduler.py @@ -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) ) 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" diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index cdc7b91..4c4addd 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -69,6 +69,38 @@ class KISBroker: def name(self) -> str: 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 --- def _ensure_token(self) -> str: # L1: in-memory diff --git a/trex_core/application/snapshot_scheduler.py b/trex_core/application/snapshot_scheduler.py index 29b304a..c873e2a 100644 --- a/trex_core/application/snapshot_scheduler.py +++ b/trex_core/application/snapshot_scheduler.py @@ -50,6 +50,7 @@ class SnapshotScheduler: alarm_emitter: Optional[AlarmEmitter] = None, warn_store: Optional[ReconcileWarningStore] = None, credential_fetch_enabled: bool = False, + kis_reinit_callback: Optional[Callable[[BrokerCredentialPayload], None]] = None, ) -> None: self._read = read_port self._store = store @@ -72,6 +73,10 @@ class SnapshotScheduler: # dcy-036 / REQ-113/114/115: credential fetch opt-in + break-glass detection. # credential_fetch_enabled=False (default) → env-only, no portfolio-hub fetch. self._credential_fetch_enabled: bool = credential_fetch_enabled + # 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). self._break_glass: bool = ( os.environ.get("TREX_KIS_BROKER_BREAK_GLASS", "").lower() @@ -206,6 +211,26 @@ class SnapshotScheduler: ctx.broker.credential_version, 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: """Publish CRITICAL DAY_START_LOAD_FAILED alarm (REQ-114).""" diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 4ad33b7..648b70e 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -122,6 +122,27 @@ def _build_transport(config: DaemonConfig) -> MessagingTransport: 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: """portfolio-hub HTTP read adapter when configured; otherwise stub (REQ-027).""" pf = config.portfolio_hub @@ -216,6 +237,9 @@ class Daemon: alarm_emitter=self.alarm_emitter, # dcy-033 / REQ-100 warn_store=self.warn_store, # BAT-050 / REQ-103: durable reconcile-warning credential_fetch_enabled=config.credential_fetch_enabled, # dcy-036 / REQ-113 + # dcy-036 / REQ-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. # When credential_fetch_enabled=False (default), entry_allowed_fn=None = no gate.