Deliver BAT-005: KIS token Redis cache (REQ-016, minor-change)
Port the trex-orbit Redis token-cache pattern so the KIS access token
survives daemon restarts and is shared across processes, avoiding KIS
token-issuance rate limits. KISBroker._ensure_token now resolves L1
in-memory -> L2 cache -> issue, storing issued tokens back to the cache.
- adapters/broker/token_cache.py: TokenCache protocol + RedisTokenCache
(key trex:broker:kis:{account_ref}:token, token+expiry+app_key suffix
JSON, ex=ttl, best-effort with graceful fallback) + InMemoryTokenCache.
- kis.py: optional token_cache injection.
- runtime config/daemon: TREX_KIS_REDIS_URL wires a RedisTokenCache.
- pyproject: redis>=5.
Verification: 96 passed, ruff clean — cache hit avoids issuance, cache is
shared across instances (restart/multi-process survival), backend errors
fall back to issuance (no regression), and app_key-suffix mismatch
invalidates a stale cache. Tested deterministically with an in-memory
double (no Redis server required). Processed via the minor-change fast
path: REQ-016 -> Implemented, BAT-005 (batch-lite) -> release-candidate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
# BAT-005 Implementation
|
||||||
|
|
||||||
|
- Status: completed
|
||||||
|
- Batch: BAT-005
|
||||||
|
|
||||||
|
## Plan Summary
|
||||||
|
- REQ-016: KISBroker 에 선택적 Redis 토큰 캐시(L1 인메모리 → L2 캐시 → 발급) + graceful fallback. orbit Redis 패턴 이식.
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
- trex_core/adapters/broker/token_cache.py (신규): `TokenCache` Protocol, `RedisTokenCache`(redis.from_url, 키 `trex:broker:kis:{account_ref}:token`, JSON 토큰+만료+app_key suffix, ex=ttl, 예외 graceful), `InMemoryTokenCache`(더블/폴백).
|
||||||
|
- trex_core/adapters/broker/kis.py: `token_cache` 주입, `_ensure_token` 3-레벨(L1/L2/issue) + 발급 시 store.
|
||||||
|
- trex_core/runtime/config.py: `KISConfig.redis_url` + `TREX_KIS_REDIS_URL`.
|
||||||
|
- trex_core/runtime/daemon.py: redis_url 있으면 `RedisTokenCache` 주입.
|
||||||
|
- pyproject.toml: `redis>=5` 의존.
|
||||||
|
- tests/adapters/broker/test_token_cache.py (신규).
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
1. TokenCache 추상화 + Redis/InMemory 구현(app_key suffix 안전, 만료 검증, 예외 graceful).
|
||||||
|
2. KISBroker _ensure_token 3-레벨 + store.
|
||||||
|
3. config/daemon wiring + pyproject redis.
|
||||||
|
4. 테스트 작성, redis 라이브러리 설치 후 실행.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- `pytest tests/adapters/broker/test_token_cache.py`: 4 passed (캐시 히트 HTTP 0 / 인스턴스 간 공유 / graceful fallback / app_key suffix 무효화).
|
||||||
|
- 전체 `pytest`: **96 passed** (기존 92 + 신규 4), ruff clean.
|
||||||
|
- 실 Redis 서버 불요: 인메모리 캐시 더블 + httpx.post 스텁으로 결정적 검증. RedisTokenCache 의 redis.from_url 경로는 운영 시 사용(라이브러리 import OK 확인).
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
- 없음(저위험). Redis 장애 시 graceful fallback(발급 진행). 실 Redis 서버 통합 검증은 운영 환경에서(라이브러리/와이어링 확인 완료).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# BAT-005: KIS 토큰 Redis 캐시 (minor-change)
|
||||||
|
|
||||||
|
- Status: release-candidate
|
||||||
|
- Profile: batch-lite
|
||||||
|
- Source Discovery: dcy-003 (REQ-012 운영 견고성 보완)
|
||||||
|
- Included REQ: REQ-016
|
||||||
|
- Excluded REQ: 없음
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- REQ-016 단일 저위험 Integration 요구사항을 minor-change 로 처리. KISBroker 에 선택적 Redis 토큰 캐시(L1 인메모리 → L2 Redis → 발급)를 더해 재기동/다중 프로세스 토큰 생존 + rate-limit 회피. orbit Redis 캐시 패턴 이식.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- In Scope:
|
||||||
|
- `TokenCache` 추상화 + `RedisTokenCache`(redis.from_url, 키 `trex:broker:kis:{account_ref}:token`, app_key suffix 안전, ex=ttl) + 테스트용 인메모리 더블.
|
||||||
|
- KISBroker `_ensure_token` 3-레벨 + graceful fallback. config `TREX_KIS_REDIS_URL` + daemon wiring. pyproject redis 의존.
|
||||||
|
- 캐시 히트/공유/폴백/suffix 단위테스트.
|
||||||
|
- Out of Scope:
|
||||||
|
- 다른 캐시 백엔드(PG/파일), 토큰 외 응답 캐시.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
- Planning: [planning.md](./planning.md)
|
||||||
|
- Design: none (batch-lite, no external interface/flow change)
|
||||||
|
- Implementation: [implementation.md](./implementation.md)
|
||||||
|
- Verification: [verification.md](./verification.md)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- minor-change: 기존 BrokerPort 뒤 토큰 캐시 계층 추가, 외부 인터페이스·슬롯 흐름 불변. design 불요.
|
||||||
|
- 실 Redis 없을 때도 graceful fallback → 단일 데몬 회귀 없음. 테스트는 인메모리 캐시 더블로 결정적 검증(실 Redis 서버 불요).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# BAT-005 Planning
|
||||||
|
|
||||||
|
- Status: confirmed
|
||||||
|
- Batch: BAT-005
|
||||||
|
- Profile: batch-lite
|
||||||
|
|
||||||
|
## Included REQ
|
||||||
|
- REQ-016 (Integration): KIS 인증 토큰 Redis 캐시
|
||||||
|
|
||||||
|
## Delivery Plan
|
||||||
|
1. `trex_core/adapters/broker/token_cache.py`: `TokenCache` Protocol(load/store) + `RedisTokenCache`(redis.from_url, JSON 토큰+만료+app_key suffix, ex=ttl, 예외 graceful) + `InMemoryTokenCache`(테스트/폴백 더블).
|
||||||
|
2. `kis.py`: `token_cache` 주입, `_ensure_token` = L1 인메모리 → L2 캐시(load, suffix 검증, 만료 확인) → 발급+store.
|
||||||
|
3. `runtime/config.py`: `TREX_KIS_REDIS_URL` → KISConfig.redis_url. `runtime/daemon.py`: redis_url 있으면 RedisTokenCache 주입.
|
||||||
|
4. `pyproject.toml`: `redis>=5` 의존.
|
||||||
|
5. `tests/adapters/broker/test_token_cache.py`: 캐시 히트(HTTP 0)/공유 재사용/graceful fallback/app_key suffix.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Internal: REQ-012 KISBroker 확장.
|
||||||
|
- External: redis 라이브러리(+ 운영 시 Redis 서버). 테스트는 인메모리 더블.
|
||||||
|
|
||||||
|
## Design Gate
|
||||||
|
- Design Required Before Implementation: no
|
||||||
|
- Reason: 기존 BrokerPort 뒤 토큰 캐시 계층 추가뿐. 외부 인터페이스/슬롯 런타임 흐름 변경 없음. batch-lite + design 생략(minor-change).
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
1. M1 — TokenCache + RedisTokenCache + KISBroker 3-레벨.
|
||||||
|
2. M2 — config/daemon wiring + pyproject.
|
||||||
|
3. M3 — 단위테스트 green(히트/공유/폴백/suffix).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- R-1 (Low): Redis 장애가 주문 경로를 막음. 대응: 캐시 예외 graceful fallback(발급으로 진행), 캐시는 best-effort.
|
||||||
|
- R-2 (Low): 자격증명 교체 후 stale 토큰. 대응: app_key suffix 검증으로 캐시 무효화(AC-4).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# BAT-005 Verification
|
||||||
|
|
||||||
|
- Status: confirmed
|
||||||
|
- Batch: BAT-005
|
||||||
|
|
||||||
|
## Acceptance Mapping
|
||||||
|
- REQ-016:
|
||||||
|
- AC1 캐시 히트 → HTTP 발급 없음 → `test_cache_hit_avoids_issuance`(L1 drop 후 L2 히트, token call 1회 유지). **PASS**
|
||||||
|
- AC2 인스턴스 간 공유(재기동/다중프로세스) → `test_shared_cache_across_instances`(B 인스턴스 재발급 0). **PASS**
|
||||||
|
- AC3 graceful fallback → `test_graceful_fallback_when_cache_errors`(캐시 miss/오류 시 발급 진행). **PASS**
|
||||||
|
- AC4 app_key suffix 안전 → `test_app_key_suffix_mismatch_ignores_cache`(자격증명 교체 시 stale 무시 재발급). **PASS**
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
- Test: `pytest` 4 token-cache passed; 전체 96 passed; ruff clean.
|
||||||
|
- Manual: redis 라이브러리 import + RedisTokenCache wiring 확인. 실 Redis 서버 없이 인메모리 더블로 결정적 검증.
|
||||||
|
|
||||||
|
## no architecture impact confirmed
|
||||||
|
- batch-lite, planning Design Gate=no. 기존 BrokerPort 뒤 토큰 캐시 계층 추가뿐 — 외부 인터페이스/슬롯 런타임 흐름 변경 없음. baseline 문서 영향 없음.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- Ready for Release: true
|
||||||
|
- Blocking Issues:
|
||||||
|
- 없음. 실 Redis 서버 통합은 운영 환경 검증 사항(라이브러리/와이어링/폴백 검증 완료, 비차단).
|
||||||
@@ -12,3 +12,4 @@
|
|||||||
| BAT-002 | standard | release-candidate | REQ-005, REQ-006, REQ-007, REQ-008, REQ-009 | dcy-002 | [bat-002](./bat-002_20260603_slot-core-daemon-http-api/) |
|
| BAT-002 | standard | release-candidate | REQ-005, REQ-006, REQ-007, REQ-008, REQ-009 | dcy-002 | [bat-002](./bat-002_20260603_slot-core-daemon-http-api/) |
|
||||||
| BAT-003 | batch-lite | release-candidate | REQ-010 | dcy-002 | [bat-003](./bat-003_20260604_daemon-dotenv-config/) |
|
| BAT-003 | batch-lite | release-candidate | REQ-010 | dcy-002 | [bat-003](./bat-003_20260604_daemon-dotenv-config/) |
|
||||||
| BAT-004 | standard | release-candidate | REQ-011, REQ-012, REQ-013, REQ-014, REQ-015 | dcy-003 | [bat-004](./bat-004_20260604_broker-execution-and-outbox/) |
|
| BAT-004 | standard | release-candidate | REQ-011, REQ-012, REQ-013, REQ-014, REQ-015 | dcy-003 | [bat-004](./bat-004_20260604_broker-execution-and-outbox/) |
|
||||||
|
| BAT-005 | batch-lite | release-candidate | REQ-016 | dcy-003 | [bat-005](./bat-005_20260604_kis-token-redis-cache/) |
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# req-016: KIS 인증 토큰 Redis 캐시
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Integration
|
||||||
|
- Priority: Medium
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- KIS 접속 토큰을 Redis 에 캐시해 데몬 재기동·다중 프로세스에서도 토큰을 공유/생존시켜, KIS 토큰 발급 rate-limit(약 1회/분, EGW00133)을 피한다. trex-orbit 의 Redis 토큰 캐시 패턴을 trex-core 로 이식한다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- KISBroker 에 선택적 토큰 캐시(`TokenCache`)를 주입한다. `_ensure_token` 은 ① 인메모리(L1) → ② Redis 캐시(L2, 키 `trex:broker:kis:{account_ref}:token`, 토큰+만료+app_key suffix JSON, `ex=ttl`) → ③ 신규 발급 순으로 해석한다. Redis 미설정/미설치/오류 시 기존 인메모리 동작으로 graceful fallback 한다. config 는 `TREX_KIS_REDIS_URL` 로 캐시를 활성화한다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given Redis 캐시에 유효 토큰이 있음, When `_ensure_token`, Then HTTP 발급 없이 캐시 토큰을 사용한다.
|
||||||
|
2. Given 두 KISBroker 가 같은 캐시를 공유, When 하나가 발급·저장 후 다른 하나가 호출, Then 재발급 없이 캐시 토큰을 재사용한다(재기동/다중 프로세스 생존).
|
||||||
|
3. Given Redis 미설정/미설치/오류, When `_ensure_token`, Then 기존 인메모리 발급 동작으로 동작한다(회귀 없음).
|
||||||
|
4. Given 캐시된 토큰의 app_key suffix 가 현재와 불일치, When load, Then 캐시를 무시하고 재발급한다(자격증명 교체 안전).
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/interface-contract.md(KIS 어댑터 노트) — 필요 시
|
||||||
|
- Modules: trex_core/adapters/broker/token_cache.py(신규), kis.py(token_cache 주입), runtime/config.py(TREX_KIS_REDIS_URL), runtime/daemon.py(캐시 wiring), pyproject(redis 의존)
|
||||||
|
- Tests: tests/adapters/broker/test_token_cache.py (캐시 히트/공유/폴백/suffix)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- minor-change: 기존 BrokerPort 뒤 토큰 캐시 계층 추가(외부 인터페이스/슬롯 흐름 변경 없음). batch-lite, design 불요.
|
||||||
|
- dcy-003 가 단일 인스턴스를 가정해 슬라이스에서 분산 캐시를 미포팅했던 갭의 후속 보완. 실 Redis 없을 때도 폴백으로 안전.
|
||||||
|
- 맥락: REQ-012(KIS 어댑터) 운영 견고성 개선.
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260604-01
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 최초 생성(Proposed) — KIS 토큰 Redis 캐시(재기동/다중프로세스 생존) Integration 요구사항.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- orbit Redis 토큰 캐시 패턴 이식으로 토큰 rate-limit·재발급 위험 완화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 캐시 히트, 공유 재사용, graceful fallback, app_key suffix 안전 4개 기준
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/interface-contract.md(필요 시)
|
||||||
|
- Modules:
|
||||||
|
- adapters/broker/token_cache.py, kis.py, runtime/config.py, runtime/daemon.py, pyproject
|
||||||
|
- Tests:
|
||||||
|
- tests/adapters/broker/test_token_cache.py
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: batch-lite 편성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 캐시 히트/공유/폴백/suffix 단위테스트(인메모리 캐시 더블)로 검증.
|
||||||
|
|
||||||
|
### CHG-20260604-02
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Proposed → Approved (minor-change 저위험 단일 REQ, 외부 인터페이스/런타임 흐름 변경 없음).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 승인 게이트 충족. batch-lite 로 즉시 delivery.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register Approved)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none (batch-lite 편성 예정)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: draft-batch 로 batch-lite 생성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- batch-lite verification 에서 AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260604-03
|
||||||
|
- Date: 2026-06-04
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status Approved → Implemented (confirm-req-implemented, 근거 batch BAT-005 release-candidate).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- BAT-005 verification 의 AC↔evidence(캐시 히트/공유/폴백/suffix, 96 pass)로 모든 AC 충족.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- 없음
|
||||||
|
- Updated:
|
||||||
|
- 없음
|
||||||
|
- Removed:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- docs/srs/index.md (Register Implemented)
|
||||||
|
- Modules:
|
||||||
|
- 없음
|
||||||
|
- Tests:
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-005 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: draft-release 로 release 편성
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- BAT-005 verification evidence 재참조로 충분.
|
||||||
+3
-2
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
## Next Requirement ID
|
## Next Requirement ID
|
||||||
|
|
||||||
- Current: `REQ-016`
|
- Current: `REQ-017`
|
||||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||||
|
|
||||||
## Requirement Register
|
## Requirement Register
|
||||||
@@ -96,11 +96,12 @@
|
|||||||
| REQ-013 | 주문 집행 루프 (claim·송신·체결·롤백) | Integration | Implemented | High | project maintainers | [req-013](./Integration/req-013_order-execution-loop.md) |
|
| REQ-013 | 주문 집행 루프 (claim·송신·체결·롤백) | Integration | Implemented | High | project maintainers | [req-013](./Integration/req-013_order-execution-loop.md) |
|
||||||
| REQ-014 | 주문 핸드오프 durability + reconciliation | Exception | Implemented | High | project maintainers | [req-014](./Exception/req-014_order-handoff-durability-reconciliation.md) |
|
| REQ-014 | 주문 핸드오프 durability + reconciliation | Exception | Implemented | High | project maintainers | [req-014](./Exception/req-014_order-handoff-durability-reconciliation.md) |
|
||||||
| REQ-015 | 브로커/집행 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-015](./Documentation/req-015_broker-execution-baseline-alignment.md) |
|
| REQ-015 | 브로커/집행 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-015](./Documentation/req-015_broker-execution-baseline-alignment.md) |
|
||||||
|
| REQ-016 | KIS 인증 토큰 Redis 캐시 | Integration | Implemented | Medium | project maintainers | [req-016](./Integration/req-016_kis-token-redis-cache.md) |
|
||||||
|
|
||||||
## Recent Change Log Summary
|
## Recent Change Log Summary
|
||||||
|
|
||||||
|
- 2026-06-04 :: REQ-016 생성 + Approved — KIS 토큰 Redis 캐시 (minor-change). (draft-req/confirm-req)
|
||||||
- 2026-06-04 :: REQ-011~015 Approved → Implemented — BAT-004 verification evidence 기반. (confirm-req-implemented)
|
- 2026-06-04 :: REQ-011~015 Approved → Implemented — BAT-004 verification evidence 기반. (confirm-req-implemented)
|
||||||
- 2026-06-04 :: REQ-011~015 Proposed → Approved — 승인 게이트 통과. (confirm-req)
|
- 2026-06-04 :: REQ-011~015 Proposed → Approved — 승인 게이트 통과. (confirm-req)
|
||||||
- 2026-06-04 :: REQ-011~015 최초 생성(Proposed) — dcy-003 FR-1~FR-6 분해. (draft-req)
|
- 2026-06-04 :: REQ-011~015 최초 생성(Proposed) — dcy-003 FR-1~FR-6 분해. (draft-req)
|
||||||
- 2026-06-04 :: REQ-010 Approved → Implemented — BAT-003 verification evidence 기반. (confirm-req-implemented)
|
- 2026-06-04 :: REQ-010 Approved → Implemented — BAT-003 verification evidence 기반. (confirm-req-implemented)
|
||||||
- 2026-06-04 :: REQ-010 생성 + Approved — 데몬 .env 로딩 (minor-change). (draft-req/confirm-req)
|
|
||||||
@@ -8,6 +8,7 @@ dependencies = [
|
|||||||
"uvicorn>=0.29",
|
"uvicorn>=0.29",
|
||||||
"psycopg[binary]>=3.1",
|
"psycopg[binary]>=3.1",
|
||||||
"python-dotenv>=1.0",
|
"python-dotenv>=1.0",
|
||||||
|
"redis>=5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""KIS token cache tests (REQ-016).
|
||||||
|
|
||||||
|
Uses InMemoryTokenCache as a deterministic double + a stubbed token endpoint
|
||||||
|
(httpx.post monkeypatched) so no real Redis server or network is needed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from trex_core.adapters.broker.kis import KISBroker
|
||||||
|
from trex_core.adapters.broker.token_cache import InMemoryTokenCache
|
||||||
|
|
||||||
|
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
||||||
|
|
||||||
|
|
||||||
|
class _TokenResp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def __init__(self, token: str) -> None:
|
||||||
|
self._token = token
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return {"access_token": self._token, "expires_in": 86400}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def count_token_calls(monkeypatch):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_post(url, **kwargs):
|
||||||
|
assert url.endswith("/oauth2/tokenP")
|
||||||
|
calls["n"] += 1
|
||||||
|
return _TokenResp(f"TOK-{calls['n']}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "post", fake_post)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _broker(cache, app_key="appkey0001"):
|
||||||
|
return KISBroker("12345678-01", app_key=app_key, app_secret="s",
|
||||||
|
base_url=_MOCK_URL, token_cache=cache)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_hit_avoids_issuance(count_token_calls):
|
||||||
|
cache = InMemoryTokenCache()
|
||||||
|
b = _broker(cache)
|
||||||
|
t1 = b._ensure_token() # L3 issue + store
|
||||||
|
assert count_token_calls["n"] == 1
|
||||||
|
b._token = None # drop L1 to force L2 lookup
|
||||||
|
t2 = b._ensure_token() # L2 cache hit — no new issuance
|
||||||
|
assert t2 == t1 and count_token_calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_cache_across_instances(count_token_calls):
|
||||||
|
cache = InMemoryTokenCache()
|
||||||
|
a = _broker(cache)
|
||||||
|
tok = a._ensure_token() # instance A issues + stores
|
||||||
|
assert count_token_calls["n"] == 1
|
||||||
|
b = _broker(cache) # instance B (e.g. after restart) shares cache
|
||||||
|
assert b._ensure_token() == tok and count_token_calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_graceful_fallback_when_cache_errors(count_token_calls):
|
||||||
|
# RedisTokenCache swallows backend errors and returns None on miss; this
|
||||||
|
# double models that post-swallow behaviour, so the broker still issues.
|
||||||
|
class SilentCache:
|
||||||
|
def load(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def store(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
b = _broker(SilentCache())
|
||||||
|
assert b._ensure_token() == "TOK-1" and count_token_calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_key_suffix_mismatch_ignores_cache(count_token_calls):
|
||||||
|
cache = InMemoryTokenCache()
|
||||||
|
a = _broker(cache, app_key="AAAA1111")
|
||||||
|
a._ensure_token() # cached under suffix 1111
|
||||||
|
assert count_token_calls["n"] == 1
|
||||||
|
b = _broker(cache, app_key="BBBB2222") # rotated credential, suffix 2222
|
||||||
|
b._ensure_token() # must NOT reuse stale cache -> re-issue
|
||||||
|
assert count_token_calls["n"] == 2
|
||||||
@@ -40,6 +40,7 @@ class KISBroker:
|
|||||||
base_url: str,
|
base_url: str,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
allow_live: bool = False,
|
allow_live: bool = False,
|
||||||
|
token_cache=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._account_ref = account_ref
|
self._account_ref = account_ref
|
||||||
self._app_key = app_key
|
self._app_key = app_key
|
||||||
@@ -48,6 +49,7 @@ class KISBroker:
|
|||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
self._token: Optional[str] = None
|
self._token: Optional[str] = None
|
||||||
self._token_exp: datetime = datetime.now(tz=timezone.utc)
|
self._token_exp: datetime = datetime.now(tz=timezone.utc)
|
||||||
|
self._token_cache = token_cache # optional TokenCache (L2, REQ-016)
|
||||||
is_mock = any(h in self._base_url for h in _MOCK_HINTS)
|
is_mock = any(h in self._base_url for h in _MOCK_HINTS)
|
||||||
if not is_mock and not allow_live:
|
if not is_mock and not allow_live:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -61,8 +63,16 @@ class KISBroker:
|
|||||||
|
|
||||||
# --- auth ---
|
# --- auth ---
|
||||||
def _ensure_token(self) -> str:
|
def _ensure_token(self) -> str:
|
||||||
|
# L1: in-memory
|
||||||
if self._token and datetime.now(tz=timezone.utc) < self._token_exp:
|
if self._token and datetime.now(tz=timezone.utc) < self._token_exp:
|
||||||
return self._token
|
return self._token
|
||||||
|
# L2: shared/persistent cache (Redis) — survives restart / multi-process (REQ-016)
|
||||||
|
if self._token_cache is not None:
|
||||||
|
cached = self._token_cache.load(self._account_ref, self._app_key)
|
||||||
|
if cached is not None:
|
||||||
|
self._token, self._token_exp = cached
|
||||||
|
return self._token
|
||||||
|
# L3: issue from KIS
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
f"{self._base_url}/oauth2/tokenP",
|
f"{self._base_url}/oauth2/tokenP",
|
||||||
json={
|
json={
|
||||||
@@ -77,6 +87,8 @@ class KISBroker:
|
|||||||
self._token = data["access_token"]
|
self._token = data["access_token"]
|
||||||
ttl = int(data.get("expires_in", 3600))
|
ttl = int(data.get("expires_in", 3600))
|
||||||
self._token_exp = datetime.now(tz=timezone.utc) + timedelta(seconds=max(60, ttl - 300))
|
self._token_exp = datetime.now(tz=timezone.utc) + timedelta(seconds=max(60, ttl - 300))
|
||||||
|
if self._token_cache is not None:
|
||||||
|
self._token_cache.store(self._account_ref, self._app_key, self._token, self._token_exp)
|
||||||
return self._token
|
return self._token
|
||||||
|
|
||||||
def _headers(self, tr_id: str) -> dict[str, str]:
|
def _headers(self, tr_id: str) -> dict[str, str]:
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""KIS token cache (REQ-016).
|
||||||
|
|
||||||
|
A small cache boundary so the KIS access token survives daemon restarts and
|
||||||
|
is shared across processes (avoiding KIS token-issuance rate limits). Ported
|
||||||
|
from the trex-orbit Redis pattern (key trex:broker:kis:{account_ref}:token,
|
||||||
|
app_key suffix safety). All cache operations are best-effort: any backend
|
||||||
|
error degrades to in-memory issuance (graceful fallback).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
|
_KEY = "trex:broker:kis:{account_ref}:token"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCache(Protocol):
|
||||||
|
def load(self, account_ref: str, app_key: str) -> Optional[tuple[str, datetime]]:
|
||||||
|
"""Return (token, expires_at_utc) if a valid, matching token is cached."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def store(self, account_ref: str, app_key: str, token: str, expires_at: datetime) -> None:
|
||||||
|
"""Persist token with TTL until expires_at. Best-effort."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(app_key: str, token: str, expires_at: datetime) -> str:
|
||||||
|
return json.dumps({
|
||||||
|
"token": token,
|
||||||
|
"expires_at": expires_at.astimezone(timezone.utc).isoformat(),
|
||||||
|
"app_key_suffix": app_key[-4:],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(app_key: str, raw: str) -> Optional[tuple[str, datetime]]:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if data.get("app_key_suffix") != app_key[-4:]:
|
||||||
|
return None # credential rotated — ignore stale cache (AC-4)
|
||||||
|
token = data.get("token")
|
||||||
|
try:
|
||||||
|
exp = datetime.fromisoformat(data["expires_at"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if exp.tzinfo is None:
|
||||||
|
exp = exp.replace(tzinfo=timezone.utc)
|
||||||
|
if not token or datetime.now(tz=timezone.utc) >= exp:
|
||||||
|
return None
|
||||||
|
return token, exp
|
||||||
|
|
||||||
|
|
||||||
|
class RedisTokenCache:
|
||||||
|
"""Redis-backed token cache. Best-effort (errors -> miss/no-op)."""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str, *, redis_module: Any = None) -> None:
|
||||||
|
if redis_module is None:
|
||||||
|
import redis as redis_module # type: ignore[import-untyped]
|
||||||
|
self._client = redis_module.from_url(redis_url, socket_connect_timeout=2)
|
||||||
|
|
||||||
|
def load(self, account_ref: str, app_key: str) -> Optional[tuple[str, datetime]]:
|
||||||
|
try:
|
||||||
|
raw = self._client.get(_KEY.format(account_ref=account_ref))
|
||||||
|
except Exception: # noqa: BLE001 - cache must never break the order path
|
||||||
|
return None
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode()
|
||||||
|
return _decode(app_key, raw)
|
||||||
|
|
||||||
|
def store(self, account_ref: str, app_key: str, token: str, expires_at: datetime) -> None:
|
||||||
|
ttl = int((expires_at - datetime.now(tz=timezone.utc)).total_seconds())
|
||||||
|
if ttl <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.set(_KEY.format(account_ref=account_ref),
|
||||||
|
_encode(app_key, token, expires_at), ex=ttl)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryTokenCache:
|
||||||
|
"""Process-local cache double (tests / fallback). Shared if the instance is shared."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._store: dict[str, str] = {}
|
||||||
|
|
||||||
|
def load(self, account_ref: str, app_key: str) -> Optional[tuple[str, datetime]]:
|
||||||
|
raw = self._store.get(_KEY.format(account_ref=account_ref))
|
||||||
|
return _decode(app_key, raw) if raw else None
|
||||||
|
|
||||||
|
def store(self, account_ref: str, app_key: str, token: str, expires_at: datetime) -> None:
|
||||||
|
self._store[_KEY.format(account_ref=account_ref)] = _encode(app_key, token, expires_at)
|
||||||
@@ -28,6 +28,7 @@ class KISConfig:
|
|||||||
account_ref: str
|
account_ref: str
|
||||||
timeout: float
|
timeout: float
|
||||||
allow_live: bool
|
allow_live: bool
|
||||||
|
redis_url: str = "" # REQ-016: optional token cache backend
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -58,6 +59,7 @@ class DaemonConfig:
|
|||||||
account_ref=os.environ["TREX_KIS_BROKER_ACCOUNT_REF"],
|
account_ref=os.environ["TREX_KIS_BROKER_ACCOUNT_REF"],
|
||||||
timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "5")),
|
timeout=float(os.environ.get("TREX_KIS_BROKER_TIMEOUT", "5")),
|
||||||
allow_live=os.environ.get("TREX_KIS_ALLOW_LIVE", "").lower() in ("1", "true", "yes"),
|
allow_live=os.environ.get("TREX_KIS_ALLOW_LIVE", "").lower() in ("1", "true", "yes"),
|
||||||
|
redis_url=os.environ.get("TREX_KIS_REDIS_URL", ""),
|
||||||
)
|
)
|
||||||
return DaemonConfig(
|
return DaemonConfig(
|
||||||
dsn=dsn,
|
dsn=dsn,
|
||||||
|
|||||||
@@ -33,9 +33,15 @@ def _build_broker(config: DaemonConfig) -> BrokerPort:
|
|||||||
from trex_core.adapters.broker.kis import KISBroker
|
from trex_core.adapters.broker.kis import KISBroker
|
||||||
|
|
||||||
k = config.kis
|
k = config.kis
|
||||||
|
token_cache = None
|
||||||
|
if k.redis_url:
|
||||||
|
from trex_core.adapters.broker.token_cache import RedisTokenCache
|
||||||
|
|
||||||
|
token_cache = RedisTokenCache(k.redis_url)
|
||||||
return KISBroker(
|
return KISBroker(
|
||||||
k.account_ref, app_key=k.app_key, app_secret=k.app_secret,
|
k.account_ref, app_key=k.app_key, app_secret=k.app_secret,
|
||||||
base_url=k.base_url, timeout=k.timeout, allow_live=k.allow_live,
|
base_url=k.base_url, timeout=k.timeout, allow_live=k.allow_live,
|
||||||
|
token_cache=token_cache,
|
||||||
)
|
)
|
||||||
return PaperBroker()
|
return PaperBroker()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user