feat: operator surface phase updates, worker backtest/kis live client, binary utilities
- Add bin/kis-paper-daily-smoke and bin/kis-sops-env utilities - Update agent-roadmap for operator surface phase - Add worker backtest bar source and Kis live client - Update KIS live secret configuration and documentation - Refine worker datacheck and daily chart price providers
This commit is contained in:
parent
2071c6fba9
commit
8c3c4ba5a2
21 changed files with 1542 additions and 223 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -4,6 +4,17 @@
|
||||||
!.env.example
|
!.env.example
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Secrets and runtime credential cache
|
||||||
|
secrets/*.env
|
||||||
|
secrets/*.plain
|
||||||
|
secrets/*.plain.*
|
||||||
|
secrets/*.decrypted
|
||||||
|
secrets/*.decrypted.*
|
||||||
|
secrets/*.tmp
|
||||||
|
secrets/*.cache
|
||||||
|
.kis-token-cache/
|
||||||
|
var/kis-token-cache/
|
||||||
|
|
||||||
.tmp/
|
.tmp/
|
||||||
.build/
|
.build/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ AI agent는 작업 전에 루트 지침과 관련 domain rule을 먼저 확인
|
||||||
- protobuf generated output은 source schema에서 생성하고 손으로 편집하지 않는다.
|
- protobuf generated output은 source schema에서 생성하고 손으로 편집하지 않는다.
|
||||||
- 새 Go module을 추가하면 `go.work`에 등록한다.
|
- 새 Go module을 추가하면 `go.work`에 등록한다.
|
||||||
- 로컬 secret이나 개인 설정 파일은 커밋하지 않는다.
|
- 로컬 secret이나 개인 설정 파일은 커밋하지 않는다.
|
||||||
- KIS 같은 provider credential은 실제 KIS smoke/live 단계에서만 필요하며, 그때도 `.env` 파일에 저장하지 않고 1Password CLI 또는 service account를 통해 실행 시점에 주입한다.
|
- KIS 같은 provider credential은 실제 KIS smoke/live 단계에서만 필요하며, agent runtime secrets는 SOPS + age를 단일 원본으로 둔다.
|
||||||
- 현재 KIS adapter mapping 참조는 로컬 cache의 공식 API 샘플 repo를 우선한다. Credential 없는 mock 검증용 샘플은 `services/worker/testdata/providers/kis/` 아래에 secret-free JSON fixture로 둔다.
|
- 현재 KIS adapter mapping 참조는 로컬 cache의 공식 API 샘플 repo를 우선한다. Credential 없는 mock 검증용 샘플은 `services/worker/testdata/providers/kis/` 아래에 secret-free JSON fixture로 둔다.
|
||||||
|
|
||||||
## 환경 변수
|
## 환경 변수
|
||||||
|
|
@ -119,9 +119,9 @@ AI agent는 작업 전에 루트 지침과 관련 domain rule을 먼저 확인
|
||||||
|
|
||||||
## Secret 관리
|
## Secret 관리
|
||||||
|
|
||||||
ALT의 provider credential은 실제 provider 호출 단계에서 1Password를 기본 secret source로 둔다. 현재 mock/fixture 테스트 단계에서는 KIS credential이 필요하지 않다. 실제 KIS smoke가 필요해지면 로컬 개발에서는 `op run -- <command>` 형태로 필요한 process에만 값을 주입하고, 자동화 환경에서는 개인 계정이 아닌 1Password service account를 사용한다.
|
ALT의 KIS provider credential은 실제 provider 호출 단계에서 SOPS + age를 agent runtime secret의 단일 원본으로 둔다. 현재 mock/fixture 테스트 단계에서는 KIS credential이 필요하지 않다. 실제 KIS smoke가 필요해지면 `bin/kis-sops-env <command>` 또는 배포 환경의 SOPS 복호화 주입 경로로 필요한 process에만 값을 주입한다.
|
||||||
|
|
||||||
Tracked 문서와 fixture에는 실제 key, token, account number, secret reference를 남기지 않는다. 환경별 secret item 이름, vault 이름, 계정별 운영 메모가 필요하면 git 추적 대상이 아닌 `agent-ops/rules/private/`에 둔다.
|
Tracked 문서와 fixture에는 실제 key, token, account number, secret reference를 남기지 않는다. 환경별 운영 메모가 필요하면 git 추적 대상이 아닌 `agent-ops/rules/private/`에 둔다.
|
||||||
|
|
||||||
## Provider Reference and Fixture
|
## Provider Reference and Fixture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
## 활성 Milestone
|
## 활성 Milestone
|
||||||
|
|
||||||
- [계획] KIS Live Data Collection Pipeline
|
- [검토중] KIS Live Data Collection Pipeline
|
||||||
- Phase: `agent-roadmap/phase/operator-surface/PHASE.md`
|
- Phase: `agent-roadmap/phase/operator-surface/PHASE.md`
|
||||||
- 경로: `agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md`
|
- 경로: `agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ Flutter client를 ALT의 공식 operator surface로 삼아 market data 상태와
|
||||||
- 요약: Flutter 실화면 전에 client core/provider/repository 경계에서 API socket 연결과 기본 market/backtest 호출을 검증했다.
|
- 요약: Flutter 실화면 전에 client core/provider/repository 경계에서 API socket 연결과 기본 market/backtest 호출을 검증했다.
|
||||||
- [계획] KIS Live Data Collection Pipeline
|
- [계획] KIS Live Data Collection Pipeline
|
||||||
- 경로: `agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md`
|
- 경로: `agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md`
|
||||||
- 요약: Flutter 실화면 전에 KIS 실 API 기반 한국장 일봉 수집, worker import/storage, backtest 입력 경계까지 검증한다.
|
- 요약: Flutter 실화면 전에 KIS 실 API 기반 한국장 일봉 수집, worker import/storage, `StorageBarSource` 입력 조회 경계까지 검증한다.
|
||||||
- [보류] Flutter Operator Console MVP
|
- [보류] Flutter Operator Console MVP
|
||||||
- 경로: `agent-roadmap/phase/operator-surface/milestones/flutter-operator-console-mvp.md`
|
- 경로: `agent-roadmap/phase/operator-surface/milestones/flutter-operator-console-mvp.md`
|
||||||
- 요약: headless 운영 검증과 화면 wireframe 승인 뒤에만 Flutter에서 market data, backtest run, result를 조회하고 실행 요청할 수 있는 MVP 화면을 구현한다.
|
- 요약: headless 운영 검증과 화면 wireframe 승인 뒤에만 Flutter에서 market data, backtest run, result를 조회하고 실행 요청할 수 있는 MVP 화면을 구현한다.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
- 결정 필요: 아래 체크리스트
|
- 결정 필요: 아래 체크리스트
|
||||||
- [x] `Operator Headless Workflow Validation`이 완료되어 화면 없이 대표 운영 흐름을 검증했다.
|
- [x] `Operator Headless Workflow Validation`이 완료되어 화면 없이 대표 운영 흐름을 검증했다.
|
||||||
- [x] `Operator Client API/Core Integration Validation`이 완료되어 Flutter 실화면 없이 client core에서 기본 API 호출을 검증했다.
|
- [x] `Operator Client API/Core Integration Validation`이 완료되어 Flutter 실화면 없이 client core에서 기본 API 호출을 검증했다.
|
||||||
- [ ] `KIS Live Data Collection Pipeline`이 완료되어 실제 KIS provider 수집과 worker import/storage/backtest 입력 경계가 검증되었다.
|
- [ ] `KIS Live Data Collection Pipeline`이 완료되어 실제 KIS provider 수집과 worker import/storage/`StorageBarSource` 입력 조회 경계가 검증되었다.
|
||||||
- [ ] 화면 wireframe 또는 동등한 화면 정의 산출물이 준비되었고 사용자가 승인했다.
|
- [ ] 화면 wireframe 또는 동등한 화면 정의 산출물이 준비되었고 사용자가 승인했다.
|
||||||
|
|
||||||
## 범위
|
## 범위
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,19 @@
|
||||||
|
|
||||||
## 목표
|
## 목표
|
||||||
|
|
||||||
Flutter 실화면 구현 전에 한국투자증권(KIS) API를 실제 provider로 사용해 한국장 일봉 데이터를 수집하고, worker import/storage/backtest 입력 경계까지 이어지는 운영 파이프라인을 검증한다. 기존 fixture/mock 기반 KIS adapter와 provider-neutral pipeline은 유지하되, live credential, token/auth, quota/error, sanitized smoke evidence를 별도 runtime 경계로 정리한다.
|
Flutter 실화면 구현 전에 한국투자증권(KIS) API를 실제 provider로 사용해 한국장 일봉 데이터를 수집하고, worker import/storage와 backtest 입력 조회 adapter 경계까지 검증한다. 기존 fixture/mock 기반 KIS adapter와 provider-neutral pipeline은 유지하되, live credential, token/auth, quota/error, sanitized smoke evidence를 별도 runtime 경계로 정리한다.
|
||||||
|
|
||||||
## 상태
|
## 상태
|
||||||
|
|
||||||
[계획]
|
[검토중]
|
||||||
|
|
||||||
## 구현 잠금
|
## 구현 잠금
|
||||||
|
|
||||||
- 상태: 잠금
|
- 상태: 해제
|
||||||
- 결정 필요: 아래 체크리스트
|
- 결정 필요: 아래 체크리스트
|
||||||
- [ ] KIS live smoke에 사용할 credential 주입 방식을 정한다. 원문 secret, 1Password item/vault 이름, 계좌번호는 tracked 문서에 기록하지 않는다.
|
- [x] KIS live smoke에 사용할 credential 주입 방식을 정한다. 원문 secret, secret-store 항목 이름, 계좌번호는 tracked 문서에 기록하지 않는다. 기준: SOPS + age 단일 원본, `bin/kis-sops-env` command-lifetime env 주입.
|
||||||
- [ ] live smoke 대상 범위를 정한다: 국내주식 일봉 `inquire-daily-itemchartprice` 단일 symbol/date range부터 시작한다.
|
- [x] live smoke 대상 범위를 정한다: 국내주식 일봉 `inquire-daily-itemchartprice` 단일 symbol/date range부터 시작한다. 기준: paper `005930`, `20240527~20240528`.
|
||||||
- [ ] live 호출 결과를 commit 가능한 sanitized fixture/evidence로 남기는 기준을 정한다.
|
- [x] live 호출 결과를 commit 가능한 sanitized fixture/evidence로 남기는 기준을 정한다. 기준: 데이터 적재가 아니라 테스트 evidence만 남기며, raw payload/header/token/key/account는 저장하지 않고 symbol/date/bar_count/msg_cd/result 같은 요약만 기록한다.
|
||||||
|
|
||||||
## 범위
|
## 범위
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ Flutter 실화면 구현 전에 한국투자증권(KIS) API를 실제 provider
|
||||||
- quota/rate-limit, auth failure, unavailable, malformed response 상태 분류
|
- quota/rate-limit, auth failure, unavailable, malformed response 상태 분류
|
||||||
- live response를 sanitized fixture/evidence로 남기는 절차
|
- live response를 sanitized fixture/evidence로 남기는 절차
|
||||||
- worker market data importer와 실제 KIS provider 연결
|
- worker market data importer와 실제 KIS provider 연결
|
||||||
- KIS live smoke가 import/storage/backtest 입력 조회까지 이어지는 end-to-end 검증
|
- KIS live smoke/import/storage 결과가 `StorageBarSource` 입력 조회까지 이어지는 adapter 검증
|
||||||
- UI MVP가 의존할 market data availability/status evidence
|
- UI MVP가 의존할 market data availability/status evidence
|
||||||
|
|
||||||
## 기능
|
## 기능
|
||||||
|
|
@ -38,23 +38,23 @@ Flutter 실화면 구현 전에 한국투자증권(KIS) API를 실제 provider
|
||||||
|
|
||||||
fixture/mock 기반 adapter를 실제 KIS provider 호출과 worker import pipeline으로 확장한다.
|
fixture/mock 기반 adapter를 실제 KIS provider 호출과 worker import pipeline으로 확장한다.
|
||||||
|
|
||||||
- [ ] [credential-boundary] KIS credential과 token을 runtime-only secret으로 주입한다. 검증: tracked 파일에 secret, item/vault 이름, 계좌번호가 남지 않고, 미설정 시 명확한 unavailable 상태가 나온다.
|
- [x] [credential-boundary] KIS credential은 command lifetime 동안만 주입하고, access token은 runtime에서 발급한다. 검증: SOPS + age 단일 원본과 `bin/kis-sops-env` command-lifetime env 주입을 사용하고, tracked 파일에 raw secret, secret-store 항목 이름, 계좌번호를 기록하지 않는다.
|
||||||
- [ ] [auth-client] KIS auth/token client가 국내주식 일봉 호출에 필요한 access token을 얻고 만료/오류 상태를 분류한다. 검증: fake auth response unit test와 credential 없는 unavailable test가 통과한다.
|
- [x] [auth-client] KIS auth/token client가 국내주식 일봉 호출에 필요한 access token을 얻고 만료/오류 상태를 분류한다. 검증: fake auth response unit test, credential 없는 unavailable test, SOPS 주입 live-gated paper smoke가 통과한다.
|
||||||
- [ ] [daily-rest-client] KIS `inquire-daily-itemchartprice` REST client가 request parameter, response decode, error/quota/malformed 상태를 처리한다. 검증: 로컬 KIS 공식 샘플 cache와 sanitized fixture 기반 test가 통과한다.
|
- [x] [daily-rest-client] KIS `inquire-daily-itemchartprice` REST client가 request parameter, response decode, error/quota/malformed 상태를 처리한다. 검증: 로컬 KIS 공식 샘플 cache, sanitized fixture 기반 test, quota/malformed/error-redaction test가 통과한다.
|
||||||
- [ ] [live-provider] worker market data importer가 KIS live provider를 선택해 일봉 데이터를 fetch할 수 있다. 검증: fake HTTP/live-gated smoke가 provider request, normalized bars, storage upsert를 확인한다.
|
- [x] [live-provider] worker market data importer가 KIS live provider를 선택해 일봉 데이터를 fetch할 수 있다. 검증: fake HTTP test가 provider request, normalized bars, importer storage upsert를 확인하고, `alt-worker`가 KIS importer handler를 등록한다.
|
||||||
- [ ] [live-smoke] credential이 준비된 환경에서 단일 symbol/date range KIS live smoke를 실행하고 sanitized evidence를 남긴다. 검증: 실제 KIS 호출 결과가 raw secret 없이 저장/조회되고, 실패 시 auth/quota/unavailable 원인이 grep 가능한 상태로 남는다.
|
- [x] [live-smoke] credential이 준비된 환경에서 단일 symbol/date range KIS live smoke를 실행하고 sanitized evidence를 남긴다. 검증: 실제 KIS paper 호출 결과를 raw secret 없이 요약 evidence로 기록하고, 실패 시 auth/quota/unavailable 원인이 grep 가능한 상태로 남는다.
|
||||||
- [ ] [backtest-input] KIS live로 수집한 normalized daily bars가 backtest 입력 조회 경계에서 사용 가능하다. 검증: 저장된 bars를 backtest fixture/input query가 읽을 수 있음을 smoke 또는 test로 확인한다.
|
- [x] [backtest-input] KIS live로 수집한 normalized daily bars가 backtest 입력 조회 adapter 경계에서 사용 가능하다. 검증: `StorageBarSource`와 KIS fake HTTP -> importer -> store -> `StorageBarSource` query test가 통과한다.
|
||||||
- [ ] [operator-handoff] Flutter Operator Console MVP로 넘길 provider readiness와 market data status key를 정리한다. 검증: 실행 명령, secret 주입 전제, sanitized evidence 경로, unavailable/error 상태를 문서화한다.
|
- [x] [operator-handoff] Flutter Operator Console MVP로 넘길 provider readiness와 market data status key를 정리한다. 검증: 실행 명령, secret 주입 전제, sanitized evidence key, unavailable/auth/quota/malformed/provider 상태를 문서화한다.
|
||||||
|
|
||||||
## 완료 리뷰
|
## 완료 리뷰
|
||||||
|
|
||||||
- 상태: 없음
|
- 상태: 요청됨
|
||||||
- 요청일: 없음
|
- 요청일: 2026-06-03
|
||||||
- 완료 근거: 아직 live KIS provider pipeline과 credential-gated smoke evidence가 없다.
|
- 완료 근거: SOPS 주입 기반 KIS paper 일봉 smoke, worker KIS auth/daily/live-provider, import/storage/`StorageBarSource` 입력 조회 evidence가 준비됐다.
|
||||||
- 리뷰 필요:
|
- 리뷰 필요:
|
||||||
- [ ] 사용자가 완료 결과를 확인했다
|
- [ ] 사용자가 완료 결과를 확인했다
|
||||||
- [ ] archive 이동을 승인했다
|
- [ ] archive 이동을 승인했다
|
||||||
- 리뷰 코멘트: 없음
|
- 리뷰 코멘트: 모든 기능 Task는 완료 후보이며, 사용자 최종 확인 전까지 활성 마일스톤으로 유지한다.
|
||||||
|
|
||||||
## 범위 제외
|
## 범위 제외
|
||||||
|
|
||||||
|
|
@ -64,19 +64,20 @@ fixture/mock 기반 adapter를 실제 KIS provider 호출과 worker import pipel
|
||||||
- 실거래 주문 API
|
- 실거래 주문 API
|
||||||
- 계좌/잔고/주문 체결 조회
|
- 계좌/잔고/주문 체결 조회
|
||||||
- production 자동 수집 스케줄링
|
- production 자동 수집 스케줄링
|
||||||
- secret 원문, 1Password item/vault 이름, 계좌번호의 tracked 문서 기록
|
- backtest start surface와 strategy runtime 운영 배선
|
||||||
|
- secret 원문, secret-store 항목 이름, 계좌번호의 tracked 문서 기록
|
||||||
|
|
||||||
## 작업 컨텍스트
|
## 작업 컨텍스트
|
||||||
|
|
||||||
- 관련 경로: `services/worker/`, `services/worker/internal/providers/kis/`, `services/worker/internal/marketdata/`, `services/worker/testdata/providers/kis/`, `packages/domain/market/`, `apps/client/`
|
- 관련 경로: `services/worker/`, `services/worker/internal/providers/kis/`, `services/worker/internal/marketdata/`, `services/worker/testdata/providers/kis/`, `packages/domain/market/`, `apps/client/`
|
||||||
- 표준선(선택): 기존 provider-neutral importer/storage/backtest 입력 경계를 유지하고, KIS 특수성은 provider adapter와 runtime config에 격리한다.
|
- 표준선(선택): 기존 provider-neutral importer/storage/backtest 입력 조회 adapter 경계를 유지하고, KIS 특수성은 provider adapter와 runtime config에 격리한다.
|
||||||
- 표준선(선택): KIS 공식 샘플은 tracked source dependency가 아니라 local cache 참조로 사용한다. private rule의 `.agent-cache/koreainvestment/open-trading-api` 경계를 따른다.
|
- 표준선(선택): KIS 공식 샘플은 tracked source dependency가 아니라 local cache 참조로 사용한다. private rule의 `.agent-cache/koreainvestment/open-trading-api` 경계를 따른다.
|
||||||
- 표준선(선택): live smoke는 credential이 준비된 환경에서만 실행하며, credential 미설정 상태는 실패가 아니라 `unavailable` evidence로 남긴다.
|
- 표준선(선택): live smoke는 credential이 준비된 환경에서만 실행하며, credential 미설정 상태는 실패가 아니라 `unavailable` evidence로 남긴다.
|
||||||
- 표준선(선택): 원격 field secret boundary는 Vaultwarden container와 host-only `sops`/`age`로 둔다. code-server 컨테이너에는 age private key나 평문 secret 파일을 두지 않고, host wrapper가 `sops exec-env`로 필요한 환경변수만 주입해 smoke를 실행한다.
|
- 표준선(선택): live smoke evidence는 데이터 적재 정책이 아니라 테스트 검증 기준이다. Commit 가능한 evidence에는 raw KIS payload/header/token/key/account를 남기지 않고, 실행 환경, symbol/date range, bar count, date span, message code, result 같은 요약만 둔다.
|
||||||
- 표준선(선택): Vaultwarden 접속 경로, host 경로, age key 위치 같은 환경 세부는 private testing rule에만 두고 tracked roadmap에는 raw secret, 계좌번호, item/vault 이름을 기록하지 않는다.
|
- 표준선(선택): 원격 field secret boundary는 SOPS + age를 agent runtime secret의 단일 원본으로 둔다. code-server 컨테이너에는 age private key나 평문 secret 파일을 두지 않고, SOPS 복호화 값을 command lifetime 동안만 환경변수로 주입해 smoke를 실행한다.
|
||||||
|
- 표준선(선택): SOPS/age key 위치와 host 경로 같은 환경 세부는 private testing rule에만 두고 tracked roadmap에는 raw secret, 계좌번호, secret-store 항목 이름을 기록하지 않는다.
|
||||||
|
- 최근 evidence: 2026-06-03 원격 host에서 `KIS_ACTIVE_ENV=paper`와 SOPS 주입으로 `bin/kis-paper-daily-smoke --symbol 005930 --start 20240527 --end 20240528` 실행 성공. Sanitized output: `KIS paper daily smoke ok`, `name=삼성전자`, `bar_count=2`, `first_date=20240527`, `last_date=20240528`, `msg_cd=MCA00000`.
|
||||||
|
- 최근 evidence: 2026-06-03 원격 host에서 `KIS_ACTIVE_ENV=paper`, `KIS_LIVE_SMOKE=1`, SOPS 주입으로 `go test ./services/worker/internal/providers/kis -run TestLiveProviderPaperSmoke -count=1 -v` 실행 성공. Sanitized output: `PASS`, `TestLiveProviderPaperSmoke`.
|
||||||
- 선행 작업: Korea Daily Data Foundation, Backtest Engine Baseline, Operator Client API/Core Integration Validation
|
- 선행 작업: Korea Daily Data Foundation, Backtest Engine Baseline, Operator Client API/Core Integration Validation
|
||||||
- 후속 작업: Flutter Operator Console MVP
|
- 후속 작업: Flutter Operator Console MVP
|
||||||
- 확인 필요:
|
- 확인 필요: 없음
|
||||||
- live smoke credential 주입 방식
|
|
||||||
- sanitized live evidence 보관 기준
|
|
||||||
- 최초 symbol/date range
|
|
||||||
|
|
|
||||||
197
bin/kis-paper-daily-smoke
Executable file
197
bin/kis-paper-daily-smoke
Executable file
|
|
@ -0,0 +1,197 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run a minimal KIS paper daily-price smoke without printing secrets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
PAPER_BASE_URL = "https://openapivts.koreainvestment.com:29443"
|
||||||
|
TOKEN_PATH = "/oauth2/tokenP"
|
||||||
|
DAILY_PATH = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||||
|
DAILY_TR_ID = "FHKST03010100"
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SmokeError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def required_env(name: str) -> str:
|
||||||
|
value = os.environ.get(name, "")
|
||||||
|
if value == "":
|
||||||
|
raise SmokeError(f"missing required env: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def redact(text: str, secrets: list[str]) -> str:
|
||||||
|
redacted = text
|
||||||
|
for secret in secrets:
|
||||||
|
if secret:
|
||||||
|
redacted = redacted.replace(secret, "[redacted]")
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str],
|
||||||
|
body: dict[str, str] | None = None,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
timeout: float,
|
||||||
|
secrets: list[str],
|
||||||
|
) -> dict:
|
||||||
|
if params:
|
||||||
|
url = f"{url}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
raw = err.read().decode("utf-8", errors="replace")
|
||||||
|
raise SmokeError(f"HTTP {err.code}: {redact(raw, secrets)}") from err
|
||||||
|
except urllib.error.URLError as err:
|
||||||
|
raise SmokeError(f"network error: {err.reason}") from err
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError as err:
|
||||||
|
raise SmokeError(f"non-JSON response: {redact(raw[:1000], secrets)}") from err
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run a KIS paper daily item chart price smoke.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--symbol", default=os.environ.get("KIS_SMOKE_SYMBOL", "005930"))
|
||||||
|
parser.add_argument("--start", default=os.environ.get("KIS_SMOKE_START", "20240527"))
|
||||||
|
parser.add_argument("--end", default=os.environ.get("KIS_SMOKE_END", "20240528"))
|
||||||
|
parser.add_argument("--timeout", type=float, default=20.0)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_yyyymmdd(name: str, value: str) -> None:
|
||||||
|
if len(value) != 8 or not value.isdigit():
|
||||||
|
raise SmokeError(f"{name} must be YYYYMMDD")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
validate_yyyymmdd("--start", args.start)
|
||||||
|
validate_yyyymmdd("--end", args.end)
|
||||||
|
if args.start > args.end:
|
||||||
|
raise SmokeError("--start must be before or equal to --end")
|
||||||
|
if args.symbol == "" or not args.symbol.isdigit():
|
||||||
|
raise SmokeError("--symbol must be a numeric KRX symbol")
|
||||||
|
|
||||||
|
active_env = os.environ.get("KIS_ACTIVE_ENV", "")
|
||||||
|
if active_env != "paper":
|
||||||
|
raise SmokeError('KIS_ACTIVE_ENV must be "paper" for this smoke')
|
||||||
|
|
||||||
|
app_key = required_env("KIS_PAPER_APP_KEY")
|
||||||
|
app_secret = required_env("KIS_PAPER_APP_SECRET")
|
||||||
|
required_env("KIS_PAPER_CANO")
|
||||||
|
product = required_env("KIS_PAPER_ACNT_PRDT_CD")
|
||||||
|
if product != "01":
|
||||||
|
raise SmokeError("KIS_PAPER_ACNT_PRDT_CD must be 01 for domestic stock paper smoke")
|
||||||
|
|
||||||
|
base_headers = {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
}
|
||||||
|
token_payload = request_json(
|
||||||
|
"POST",
|
||||||
|
PAPER_BASE_URL + TOKEN_PATH,
|
||||||
|
headers=base_headers,
|
||||||
|
body={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"appkey": app_key,
|
||||||
|
"appsecret": app_secret,
|
||||||
|
},
|
||||||
|
timeout=args.timeout,
|
||||||
|
secrets=[app_key, app_secret],
|
||||||
|
)
|
||||||
|
access_token = token_payload.get("access_token", "")
|
||||||
|
if access_token == "":
|
||||||
|
raise SmokeError(
|
||||||
|
"token response missing access_token: "
|
||||||
|
+ redact(json.dumps(token_payload, ensure_ascii=False), [app_key, app_secret])
|
||||||
|
)
|
||||||
|
|
||||||
|
query_payload = request_json(
|
||||||
|
"GET",
|
||||||
|
PAPER_BASE_URL + DAILY_PATH,
|
||||||
|
headers={
|
||||||
|
**base_headers,
|
||||||
|
"authorization": f"Bearer {access_token}",
|
||||||
|
"appkey": app_key,
|
||||||
|
"appsecret": app_secret,
|
||||||
|
"tr_id": DAILY_TR_ID,
|
||||||
|
"custtype": "P",
|
||||||
|
"tr_cont": "",
|
||||||
|
},
|
||||||
|
params={
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": args.symbol,
|
||||||
|
"FID_INPUT_DATE_1": args.start,
|
||||||
|
"FID_INPUT_DATE_2": args.end,
|
||||||
|
"FID_PERIOD_DIV_CODE": "D",
|
||||||
|
"FID_ORG_ADJ_PRC": "0",
|
||||||
|
},
|
||||||
|
timeout=args.timeout,
|
||||||
|
secrets=[app_key, app_secret, access_token],
|
||||||
|
)
|
||||||
|
|
||||||
|
if query_payload.get("rt_cd") != "0":
|
||||||
|
raise SmokeError(
|
||||||
|
"KIS daily query failed: "
|
||||||
|
+ redact(json.dumps(query_payload, ensure_ascii=False), [app_key, app_secret, access_token])
|
||||||
|
)
|
||||||
|
|
||||||
|
output1 = query_payload.get("output1") or {}
|
||||||
|
output2 = query_payload.get("output2") or []
|
||||||
|
if not isinstance(output2, list) or len(output2) == 0:
|
||||||
|
raise SmokeError("KIS daily query returned no output2 rows")
|
||||||
|
|
||||||
|
dates = sorted(
|
||||||
|
row.get("stck_bsop_date", "")
|
||||||
|
for row in output2
|
||||||
|
if isinstance(row, dict) and row.get("stck_bsop_date")
|
||||||
|
)
|
||||||
|
if not dates:
|
||||||
|
raise SmokeError("KIS daily rows did not include stck_bsop_date")
|
||||||
|
|
||||||
|
print("KIS paper daily smoke ok")
|
||||||
|
print(f"symbol={args.symbol}")
|
||||||
|
print(f"name={output1.get('hts_kor_isnm', '')}")
|
||||||
|
print(f"bar_count={len(output2)}")
|
||||||
|
print(f"first_date={dates[0]}")
|
||||||
|
print(f"last_date={dates[-1]}")
|
||||||
|
print(f"msg_cd={query_payload.get('msg_cd', '')}")
|
||||||
|
print(f"msg={query_payload.get('msg1', '')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
|
except SmokeError as err:
|
||||||
|
print(f"KIS paper daily smoke failed: {err}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
47
bin/kis-sops-env
Executable file
47
bin/kis-sops-env
Executable file
|
|
@ -0,0 +1,47 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
echo "usage: bin/kis-sops-env <command> [args...]" >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
|
||||||
|
sops_bin="${SOPS_BIN:-sops}"
|
||||||
|
if ! command -v "$sops_bin" >/dev/null 2>&1; then
|
||||||
|
if [[ -x /opt/homebrew/bin/sops ]]; then
|
||||||
|
sops_bin="/opt/homebrew/bin/sops"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v "$sops_bin" >/dev/null 2>&1; then
|
||||||
|
echo "sops is required to load KIS runtime secrets" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
secret_file="${KIS_SOPS_FILE:-$repo_root/secrets/kis.live.sops.yaml}"
|
||||||
|
|
||||||
|
if [[ ! -f "$secret_file" ]]; then
|
||||||
|
echo "KIS SOPS file not found: $secret_file" >&2
|
||||||
|
exit 66
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$sops_bin" -d --output-type json "$secret_file" | python3 -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
cmd = sys.argv[1:]
|
||||||
|
if not cmd:
|
||||||
|
raise SystemExit("missing command")
|
||||||
|
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
env = os.environ.copy()
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, (str, int, float, bool)):
|
||||||
|
env[str(key)] = str(value)
|
||||||
|
|
||||||
|
os.execvpe(cmd[0], cmd, env)
|
||||||
|
' "$@"
|
||||||
|
|
@ -1,169 +1,90 @@
|
||||||
# KIS Live Secret Guide
|
# KIS Live Secret Guide
|
||||||
|
|
||||||
이 문서는 KIS live smoke를 준비할 때 사용자가 어떤 값을 어디에 넣고, 에이전트에게 무엇만 알려주면 되는지 정리한다. secret 원문, 계좌번호, 비밀번호, Vaultwarden item 이름, vault 이름은 이 문서와 task/roadmap/log에 기록하지 않는다.
|
한국투자증권 Open API 기반 quant agent가 사용할 장기 비밀값은 `SOPS + age`로 관리한다.
|
||||||
|
|
||||||
## 원칙
|
## 1. repo 위치로 이동
|
||||||
|
|
||||||
- Vaultwarden은 원격 host의 code-server와 동레벨 container로 둔다.
|
원격 host의 ALT repo에서만 진행한다.
|
||||||
- `sops`와 `age`는 원격 host에만 설치한다.
|
|
||||||
- code-server container 안에는 age private key, 평문 secret 파일, KIS credential 원문을 두지 않는다.
|
|
||||||
- KIS smoke는 원격 host wrapper가 `sops exec-env`로 필요한 환경변수만 주입해서 code-server container 안의 명령을 실행한다.
|
|
||||||
- 원격 dev/test credential은 조회/smoke 최소 권한을 원칙으로 한다.
|
|
||||||
- 주문, 잔고, 계좌 비밀번호는 KIS live data collection smoke 범위에 넣지 않는다.
|
|
||||||
|
|
||||||
## 사용자가 준비할 것
|
|
||||||
|
|
||||||
Vaultwarden에 KIS 관련 원본 secret을 직접 저장한다. 채팅이나 tracked 파일에 원문을 붙여넣지 않는다.
|
|
||||||
|
|
||||||
SOPS 암호화 파일에는 KIS live smoke에 필요한 값만 환경변수 이름으로 저장한다. 국내주식 일봉 조회 smoke의 기본 후보는 아래와 같다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
KIS_APP_KEY
|
|
||||||
KIS_APP_SECRET
|
|
||||||
KIS_BASE_URL
|
|
||||||
KIS_IS_PAPER
|
|
||||||
```
|
|
||||||
|
|
||||||
계좌번호나 계좌 상품코드는 일봉 시세 조회에 필요할 때만 별도 범위로 추가한다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
KIS_ACCOUNT_NO
|
|
||||||
KIS_ACCOUNT_PRODUCT
|
|
||||||
```
|
|
||||||
|
|
||||||
## Vaultwarden에 원본 저장
|
|
||||||
|
|
||||||
원격 Vaultwarden은 공개 포트가 아니라 원격 host loopback에만 열린다. 먼저 SSH tunnel을 연다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -L 18088:127.0.0.1:18088 toki@192.168.0.97
|
|
||||||
```
|
|
||||||
|
|
||||||
브라우저에서 아래 주소로 접속한다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:18088
|
|
||||||
```
|
|
||||||
|
|
||||||
처음 사용하는 경우:
|
|
||||||
|
|
||||||
1. Vaultwarden 계정을 만든다.
|
|
||||||
2. KIS 원본 credential을 저장할 항목을 만든다.
|
|
||||||
3. 항목 이름, vault 이름, 계좌번호, 비밀번호, app secret 원문은 이 repo 문서나 채팅에 쓰지 않는다.
|
|
||||||
4. 첫 계정을 만든 뒤에는 원격 host의 Vaultwarden compose `.env`에서 `SIGNUPS_ALLOWED=false`로 바꾸고 Vaultwarden을 재시작한다.
|
|
||||||
|
|
||||||
KIS 항목에는 실제 원본 값을 사람이 알아볼 수 있게 저장한다. 예시는 항목 구성 방식만 보여주며 값은 쓰지 않는다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
KIS app key
|
|
||||||
KIS app secret
|
|
||||||
KIS base URL
|
|
||||||
KIS paper/live 구분
|
|
||||||
```
|
|
||||||
|
|
||||||
일봉 조회 smoke에 계좌 정보가 필요하지 않으면 계좌번호, 계좌 상품코드, 계좌 비밀번호, 주문 비밀번호는 넣지 않거나 별도 항목으로 분리한다.
|
|
||||||
|
|
||||||
## SOPS 파일에 smoke용 env 저장
|
|
||||||
|
|
||||||
SOPS 파일은 에이전트와 테스트 명령이 읽을 수 있는 암호화된 env 파일이다. Vaultwarden은 원본 금고이고, SOPS 파일은 smoke 실행에 필요한 최소 값만 담는다.
|
|
||||||
|
|
||||||
원격 host에서 ALT repo로 이동한다.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/toki/docker/services/code-server/data/volume/workspace/alt
|
cd /Users/toki/docker/services/code-server/data/volume/workspace/alt
|
||||||
```
|
```
|
||||||
|
|
||||||
아래 명령으로 암호화된 파일을 연다.
|
## 2. SOPS 파일 열기
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
|
EDITOR="${EDITOR:-nano}" \
|
||||||
sops secrets/kis.live.sops.yaml
|
sops secrets/kis.live.sops.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
편집기가 열리면 아래 키들의 오른쪽 값을 채운다.
|
## 3. 템플릿 붙여넣기
|
||||||
|
|
||||||
|
2번 명령으로 열린 편집기 안에서 `secrets/kis.live.sops.yaml`의 전체 내용을 아래 형태로 맞춘다. 각 값 설명을 실제 값으로 바꾼 뒤 저장하고 편집기를 종료한다.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
KIS_APP_KEY: "..."
|
KIS_REAL_APP_KEY: "<한국투자증권 실전 Open API 앱키>"
|
||||||
KIS_APP_SECRET: "..."
|
KIS_REAL_APP_SECRET: "<한국투자증권 실전 Open API 앱시크릿>"
|
||||||
KIS_BASE_URL: "..."
|
KIS_REAL_CANO: "<실전 증권계좌번호 8자리>"
|
||||||
KIS_IS_PAPER: "true"
|
KIS_REAL_ACNT_PRDT_CD: "01"
|
||||||
|
KIS_PAPER_APP_KEY: "<한국투자증권 모의투자 Open API 앱키>"
|
||||||
|
KIS_PAPER_APP_SECRET: "<한국투자증권 모의투자 Open API 앱시크릿>"
|
||||||
|
KIS_PAPER_CANO: "<모의투자 증권계좌번호 8자리>"
|
||||||
|
KIS_PAPER_ACNT_PRDT_CD: "01"
|
||||||
```
|
```
|
||||||
|
|
||||||
입력 기준:
|
국내주식 종합계좌 기준 `ACNT_PRDT_CD`는 `01`로 둔다. 한국투자증권 공식 샘플도 증권계좌 8자리와 `my_prod: "01"`을 분리해서 사용한다.
|
||||||
|
|
||||||
- `KIS_APP_KEY`: KIS에서 발급받은 app key
|
실제 값은 문서, 채팅, 로그에 쓰지 않는다.
|
||||||
- `KIS_APP_SECRET`: KIS에서 발급받은 app secret
|
|
||||||
- `KIS_BASE_URL`: KIS 실전 또는 모의투자 API base URL
|
|
||||||
- `KIS_IS_PAPER`: 모의투자면 `"true"`, 실전이면 `"false"`
|
|
||||||
|
|
||||||
저장 후 종료하면 SOPS가 파일을 다시 암호화한다. 저장된 파일에서 실제 값이 `ENC[...]` 형태로 보이면 정상이다.
|
`nano` 기준 저장은 `Ctrl+O`, `Enter`, 종료는 `Ctrl+X`다.
|
||||||
아직 값을 넣지 않은 빈 placeholder는 `""`로 보일 수 있다. 실제 credential을 넣은 뒤에도 값이 평문으로 보이면 저장이 잘못된 것이다.
|
|
||||||
|
|
||||||
```bash
|
## 4. 입력 확인
|
||||||
sed -n '1,80p' secrets/kis.live.sops.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
복호화 확인이 필요하면 원격 host에서만 아래 명령을 실행한다. 출력에는 secret 원문이 나오므로 화면 공유, 로그 저장, 채팅 복사를 하지 않는다.
|
값을 출력하지 않고 필요한 key가 모두 들어왔는지만 확인한다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
sops --decrypt secrets/kis.live.sops.yaml
|
bin/kis-sops-env bash -lc '
|
||||||
|
for key in \
|
||||||
|
KIS_REAL_APP_KEY KIS_REAL_APP_SECRET KIS_REAL_CANO KIS_REAL_ACNT_PRDT_CD \
|
||||||
|
KIS_PAPER_APP_KEY KIS_PAPER_APP_SECRET KIS_PAPER_CANO KIS_PAPER_ACNT_PRDT_CD
|
||||||
|
do
|
||||||
|
test -n "${!key}" || exit 1
|
||||||
|
done
|
||||||
|
echo "KIS SOPS secrets are ready"
|
||||||
|
'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 에이전트에게 알려줄 것
|
## 5. 모의테스트 실행 준비 확인
|
||||||
|
|
||||||
에이전트에게 secret 원문을 알려주지 않는다. 아래 메타 정보만 알려준다.
|
모의테스트 command는 `KIS_ACTIVE_ENV=paper`를 일반 환경변수로 주고, secret은 SOPS에서 주입한다.
|
||||||
|
|
||||||
```text
|
|
||||||
1. Vaultwarden에 KIS 항목을 만들었다.
|
|
||||||
2. SOPS 파일에 다음 env 이름으로 저장했다:
|
|
||||||
- KIS_APP_KEY
|
|
||||||
- KIS_APP_SECRET
|
|
||||||
- KIS_BASE_URL
|
|
||||||
- KIS_IS_PAPER
|
|
||||||
3. 첫 smoke 대상:
|
|
||||||
- market: KR
|
|
||||||
- symbol: 005930
|
|
||||||
- date range: 20240501~20240531
|
|
||||||
```
|
|
||||||
|
|
||||||
## 실행 흐름
|
|
||||||
|
|
||||||
에이전트가 준비할 수 있는 항목:
|
|
||||||
|
|
||||||
- `.sops.yaml`
|
|
||||||
- `secrets/kis.live.sops.yaml` 템플릿
|
|
||||||
- KIS smoke wrapper script
|
|
||||||
- code-server container에 env만 주입하는 실행 명령
|
|
||||||
|
|
||||||
현재 repo에는 `.sops.yaml`과 암호화된 `secrets/kis.live.sops.yaml` 템플릿이 준비되어 있다. 현재 설정 요약과 다음 에이전트 handoff는 `docs/kis-live-secret-handoff.md`를 본다.
|
|
||||||
|
|
||||||
사용자는 원격 host에서 `sops` 편집기로 secret 값을 직접 입력한다.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
KIS_ACTIVE_ENV=paper \
|
||||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
sops secrets/kis.live.sops.yaml
|
bin/kis-sops-env bash -lc '
|
||||||
|
test "$KIS_ACTIVE_ENV" = "paper"
|
||||||
|
test -n "$KIS_PAPER_APP_KEY"
|
||||||
|
test -n "$KIS_PAPER_APP_SECRET"
|
||||||
|
test -n "$KIS_PAPER_CANO"
|
||||||
|
test -n "$KIS_PAPER_ACNT_PRDT_CD"
|
||||||
|
echo "paper smoke env is ready"
|
||||||
|
'
|
||||||
```
|
```
|
||||||
|
|
||||||
KIS live smoke는 host-only age key를 사용해 실행한다.
|
첫 모의 smoke는 삼성전자 `005930` 일봉 조회로 실행한다. 토큰과 secret은 출력하지 않고 결과 요약만 출력한다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
KIS_ACTIVE_ENV=paper \
|
||||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
sops exec-env secrets/kis.live.sops.yaml -- \
|
bin/kis-sops-env bin/kis-paper-daily-smoke \
|
||||||
docker exec \
|
--symbol 005930 \
|
||||||
-e KIS_APP_KEY \
|
--start 20240527 \
|
||||||
-e KIS_APP_SECRET \
|
--end 20240528
|
||||||
-e KIS_BASE_URL \
|
|
||||||
-e KIS_IS_PAPER \
|
|
||||||
code-server bash -lc 'cd /config/workspace/alt && bin/kis-live-smoke'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 완료 조건
|
## 6. 확인 체크리스트
|
||||||
|
|
||||||
KIS live smoke가 완료되었다고 보려면 다음 evidence가 필요하다.
|
- [ ] SOPS에는 실전/모의 8개 값만 있다.
|
||||||
|
- [ ] secret 값이 문서, 로그, 테스트 fixture에 출력되지 않았다.
|
||||||
- 실제 KIS 일봉 조회가 credential 준비 환경에서 실행되었다.
|
|
||||||
- raw secret 없이 sanitized response 또는 요약 evidence가 남았다.
|
|
||||||
- 실패 시 auth, quota, unavailable, malformed response 중 어떤 상태인지 grep 가능한 출력으로 남았다.
|
|
||||||
- 수집된 normalized daily bars가 worker import/storage 경계를 통과했다.
|
|
||||||
- 저장된 bars가 backtest 입력 조회 경계에서 사용 가능함을 확인했다.
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,136 @@
|
||||||
# KIS Live Secret Handoff
|
# KIS Live Secret Handoff
|
||||||
|
|
||||||
## Current Setup
|
## 현재 기준
|
||||||
|
|
||||||
- Remote field host owns the secret boundary.
|
- KIS agent runtime secret의 단일 원본은 SOPS + age다.
|
||||||
- Vaultwarden runs outside the code-server container as a sibling container.
|
- 모의테스트도 필요하므로 `secrets/kis.live.sops.yaml`에 실전/모의 값을 분리해 저장한다.
|
||||||
- `sops` and `age` are installed on the remote host only.
|
- `bin/kis-sops-env`는 명령 실행 동안만 SOPS 값을 환경변수로 주입한다.
|
||||||
- The remote host age recipient for KIS live smoke is:
|
|
||||||
|
|
||||||
```text
|
## 사용자 수행 순서
|
||||||
age1fwqdkmqh3ykq7cnchcrr5nfwr77qsdt4p79lpa49g6q5k88cluvqkfw8d3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Repo Files
|
원격 host의 ALT repo로 이동한다.
|
||||||
|
|
||||||
- `.sops.yaml`: SOPS rule for `secrets/kis.live.sops.yaml`.
|
|
||||||
- `secrets/kis.live.sops.yaml`: encrypted KIS live smoke env template.
|
|
||||||
- `docs/kis-live-secret-guide.md`: user-facing guide for how to fill and use the secret.
|
|
||||||
|
|
||||||
## User Fill-In
|
|
||||||
|
|
||||||
Do not paste secret values into chat, roadmap, docs, task logs, or command output.
|
|
||||||
|
|
||||||
Open the Vaultwarden SSH tunnel from the local machine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh -L 18088:127.0.0.1:18088 toki@192.168.0.97
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open Vaultwarden in the browser:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:18088
|
|
||||||
```
|
|
||||||
|
|
||||||
On the remote host, edit the encrypted file:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/toki/docker/services/code-server/data/volume/workspace/alt
|
cd /Users/toki/docker/services/code-server/data/volume/workspace/alt
|
||||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" sops secrets/kis.live.sops.yaml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Fill only the env names required for first KIS daily-bar smoke:
|
SOPS 편집기로 `secrets/kis.live.sops.yaml`을 연다.
|
||||||
|
|
||||||
```text
|
```bash
|
||||||
KIS_APP_KEY
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
KIS_APP_SECRET
|
EDITOR="${EDITOR:-nano}" \
|
||||||
KIS_BASE_URL
|
sops secrets/kis.live.sops.yaml
|
||||||
KIS_IS_PAPER
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Account number, account product, account password, order password, balance, and order scopes are not part of the first daily-bar smoke unless a later task explicitly expands the scope.
|
열린 편집기 안에서 `secrets/kis.live.sops.yaml`의 전체 내용을 아래 템플릿 형태로 맞춘다. 각 값 설명만 실제 값으로 바꾼 뒤 저장하고 종료한다.
|
||||||
|
|
||||||
## Agent Handoff
|
```yaml
|
||||||
|
KIS_REAL_APP_KEY: "<한국투자증권 실전 Open API 앱키>"
|
||||||
|
KIS_REAL_APP_SECRET: "<한국투자증권 실전 Open API 앱시크릿>"
|
||||||
|
KIS_REAL_CANO: "<실전 증권계좌번호 8자리>"
|
||||||
|
KIS_REAL_ACNT_PRDT_CD: "01"
|
||||||
|
KIS_PAPER_APP_KEY: "<한국투자증권 모의투자 Open API 앱키>"
|
||||||
|
KIS_PAPER_APP_SECRET: "<한국투자증권 모의투자 Open API 앱시크릿>"
|
||||||
|
KIS_PAPER_CANO: "<모의투자 증권계좌번호 8자리>"
|
||||||
|
KIS_PAPER_ACNT_PRDT_CD: "01"
|
||||||
|
```
|
||||||
|
|
||||||
After the user fills the encrypted file, tell the agent only:
|
국내주식 종합계좌 기준 `ACNT_PRDT_CD`는 `01`로 둔다. 한국투자증권 공식 샘플도 증권계좌 8자리와 `my_prod: "01"`을 분리해서 사용한다.
|
||||||
|
|
||||||
|
`nano` 기준 저장은 `Ctrl+O`, `Enter`, 종료는 `Ctrl+X`다.
|
||||||
|
|
||||||
|
## 입력 확인 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
|
bin/kis-sops-env bash -lc '
|
||||||
|
for key in \
|
||||||
|
KIS_REAL_APP_KEY KIS_REAL_APP_SECRET KIS_REAL_CANO KIS_REAL_ACNT_PRDT_CD \
|
||||||
|
KIS_PAPER_APP_KEY KIS_PAPER_APP_SECRET KIS_PAPER_CANO KIS_PAPER_ACNT_PRDT_CD
|
||||||
|
do
|
||||||
|
test -n "${!key}" || exit 1
|
||||||
|
done
|
||||||
|
echo "KIS SOPS secrets are ready"
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 모의테스트 준비 확인 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIS_ACTIVE_ENV=paper \
|
||||||
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
|
bin/kis-sops-env bash -lc '
|
||||||
|
test "$KIS_ACTIVE_ENV" = "paper"
|
||||||
|
test -n "$KIS_PAPER_APP_KEY"
|
||||||
|
test -n "$KIS_PAPER_APP_SECRET"
|
||||||
|
test -n "$KIS_PAPER_CANO"
|
||||||
|
test -n "$KIS_PAPER_ACNT_PRDT_CD"
|
||||||
|
echo "paper smoke env is ready"
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 모의 일봉 smoke 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIS_ACTIVE_ENV=paper \
|
||||||
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
|
bin/kis-sops-env bin/kis-paper-daily-smoke \
|
||||||
|
--symbol 005930 \
|
||||||
|
--start 20240527 \
|
||||||
|
--end 20240528
|
||||||
|
```
|
||||||
|
|
||||||
|
## Go live-provider smoke 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KIS_ACTIVE_ENV=paper \
|
||||||
|
KIS_LIVE_SMOKE=1 \
|
||||||
|
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||||
|
bin/kis-sops-env /opt/homebrew/bin/go test ./services/worker/internal/providers/kis \
|
||||||
|
-run TestLiveProviderPaperSmoke \
|
||||||
|
-count=1 \
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sanitized evidence 기준
|
||||||
|
|
||||||
|
이 기준은 데이터 적재 정책이 아니라 테스트 evidence 기준이다. Raw KIS payload, request/response header, token, key, 계좌번호는 저장하지 않는다.
|
||||||
|
|
||||||
|
Tracked 문서나 handoff에는 아래 요약만 남긴다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SOPS file is filled.
|
env=paper
|
||||||
First smoke target:
|
provider=kis
|
||||||
|
endpoint=inquire-daily-itemchartprice
|
||||||
|
symbol=005930
|
||||||
|
date_range=20240527~20240528
|
||||||
|
bar_count=2
|
||||||
|
first_date=20240527
|
||||||
|
last_date=20240528
|
||||||
|
msg_cd=MCA00000
|
||||||
|
result=ok
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider 상태 분류는 아래 key를 사용한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
unavailable
|
||||||
|
auth
|
||||||
|
quota
|
||||||
|
malformed
|
||||||
|
provider
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent에게 전달할 내용
|
||||||
|
|
||||||
|
SOPS 입력과 확인 명령이 끝난 뒤 agent에게는 아래만 전달한다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
SOPS KIS real/paper runtime secrets are filled.
|
||||||
|
Paper smoke env readiness check passed.
|
||||||
|
Go KIS paper live-provider smoke passed.
|
||||||
|
First paper smoke target:
|
||||||
- market: KR
|
- market: KR
|
||||||
- symbol: 005930
|
- symbol: 005930
|
||||||
- date range: <YYYYMMDD>~<YYYYMMDD>
|
- date range: 20240527~20240528
|
||||||
```
|
```
|
||||||
|
|
||||||
The agent should then implement or run a host-wrapper flow that decrypts on the remote host and passes env vars into the code-server container only for the command lifetime.
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
KIS_APP_KEY: ""
|
KIS_REAL_APP_KEY: ENC[AES256_GCM,data:eoKvNJIjaIzTfh84SMW9TUElBEfyvc8/cCil4pkWNQG7B7x3,iv:2Hln0LpyBujjHpWo64dQLGSMYmYq+HNLPOGzH+YR6eA=,tag:sfhN86asqnmjyHxBka7t+A==,type:str]
|
||||||
KIS_APP_SECRET: ""
|
KIS_REAL_APP_SECRET: ENC[AES256_GCM,data:a1tlH+4ngendHK/eUFHQkMbFFbUXOXts/uwqeNZB90SwDMhJAgoh6dxyQsOxjM4aSRb9Ge+D00aOgWo/izvzUibx3YVDgjcuLUtX6Suh4lChl/wGA78yqcLTotVNel9YE5sMlTp4+Oc8fi/iPAFu2G0rQRXdV7AEpwb0o+ya48zRBO3G5rH81LYL4yHgx8FdH2G9Gup1A6iv7GUPhZ5Dux1QWvUpNAaPjCtGl1VnmMlSUNvF,iv:R/t4CtwP6fK9AGBdgjNpxjeZui31S8SA/EHhJWEn3UI=,tag:ng36SyQcq2NA1jHucTRrlg==,type:str]
|
||||||
KIS_BASE_URL: ""
|
KIS_REAL_CANO: ENC[AES256_GCM,data:OP7hLH/nlms=,iv:BoWAHHEhRW0lWtyM16KaIv5CAiQfGE51sJZ16qtZ6Zw=,tag:Pq97AvZAKexrsEQWpuJQbg==,type:str]
|
||||||
KIS_IS_PAPER: ENC[AES256_GCM,data:BqMakQ==,iv:AJEP2eaQeYSQfWP8BhfcDR946tSQHUT6EPWV8EE4Txc=,tag:nsjz0h9Eh2H2/lru3qwmyQ==,type:str]
|
KIS_REAL_ACNT_PRDT_CD: ENC[AES256_GCM,data:EIs=,iv:YpY1n6HrR7LVEGHlsH/f6VSMDkwHDYpmitsoPfvVWmk=,tag:cxyfjRvCYLaXL2z7MGD4zA==,type:str]
|
||||||
|
KIS_PAPER_APP_KEY: ENC[AES256_GCM,data:nHg5DoiQjYKEj35mltasy3TYYKONbqYig8NIV8p9HboxEWL+,iv:22CxPjTFZ/e7CcnblZapnlsO8AlIB9z3nREtzsXMnm0=,tag:z0J3Z40hcRoBe42zDTcG9w==,type:str]
|
||||||
|
KIS_PAPER_APP_SECRET: ENC[AES256_GCM,data:Ui+ZsN1c5N6NoE/EIYKN/iGXYVPWUpwfxPdl7AXYfl4zAtTFukduyxWqpS4IHE1nBlsvzGyBZKD/1hztDiNa42+kEaa8pS3F3NrwxI3R8ELmeg1zwN4TuWDgR0BjPgs+WEZ0p9SRBBRNQnZo+qnFHENc+V4gDlLb/x7u0GXlbxBfqY7yxMhH/+81Y2x6qrADX7xN1dDI79HIvFftbG9VCKQDHe00m1hHadwRMcYuVuCaQRa7,iv:Jve0b71pKh9oWQuHdHtxMZyKgGe5OjqLKGUwUrCk9wE=,tag:vwJ+2QDnMI8Oo35YwGp1IQ==,type:str]
|
||||||
|
KIS_PAPER_CANO: ENC[AES256_GCM,data:cOxTv7vTcHc=,iv:13MVQ+0+a+r7GeoptErFdEfkiueD8upCNEFTKoVUEJs=,tag:1O6wVRmEzU3Tn0IlnhhzsQ==,type:str]
|
||||||
|
KIS_PAPER_ACNT_PRDT_CD: ENC[AES256_GCM,data:jF0=,iv:gL5WqKsZVe8EL83fE5E9S5KswShE4QkIaj3GjHAf9QA=,tag:osBa//pTmY45FxnvLFXCBQ==,type:str]
|
||||||
sops:
|
sops:
|
||||||
age:
|
age:
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5cVJCUlNwcHcrN2lOM1g3
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmWW5pcnFFbUxsSXRReDJR
|
||||||
U2NHL3VMdkhnQ1V6Y3NlNFdGOW5HdTlnZFdvCmtyREUySFp0VXFHUk5tQmE0L1Yz
|
K01PK0hYMDZGK1hnaHY2SWVPM1NJYWEzZ0hnCjVCTS95WWI0N3ZXc0krU1E4dHpt
|
||||||
VTBXQUFoWWRmSDQyYlE2QTVtRzk1R3cKLS0tIDNTS3ljYjI0OFZVeWJhb1ZuTUNQ
|
b0dvZmtzQjQ5eXNBelVGUlZtZGNPdHMKLS0tIEVuNWFGbC9hUm9EKzBsdE0wZTlm
|
||||||
U2c4Z0tRNHFSVkVja2dMZnBtM3pQeEEKJbdpCZ3XAWQaRqe3GbqsdIXDEjjEuLkT
|
Ujd0OTgvRlg3S2phbWpxS3puTXYwSkEKBUgasZctfaUyWopf1CgCr7gXHFulUQe1
|
||||||
HQUSiPEpFJcj8nunztDM6YQZS/QDJLNv2nWOxuts5AHoWaQEVAKkCw==
|
NDvkGcFewWE9eVAX824Acca7YAzZcpisXJtiyfDXAQ/6J8B+3hc1SQ==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1fwqdkmqh3ykq7cnchcrr5nfwr77qsdt4p79lpa49g6q5k88cluvqkfw8d3
|
recipient: age1fwqdkmqh3ykq7cnchcrr5nfwr77qsdt4p79lpa49g6q5k88cluvqkfw8d3
|
||||||
lastmodified: "2026-06-02T05:31:11Z"
|
lastmodified: "2026-06-03T00:15:30Z"
|
||||||
mac: ENC[AES256_GCM,data:QSdh4ZoAE97c9TzQoe1baZefZ5Rk2vaRw97lKnR2h/iMRjTYbHgkP75Werd8T5oDH20q1o4IkjmOjyEk1XpHDZ9HThKsYmMI3xDFh/64Gz6rW8oCs8dEKW+j7AZ11DleGDkWHu4bIXk5HEeP2GNb7WJufpeIad0Ci3SyBGfJ+aA=,iv:5/BretO0xOrJ4o3jSSRlH0tIccM2wxaYg2TgiXj+gFk=,tag:8hxUFjbL/Q1UN74k1upYgQ==,type:str]
|
mac: ENC[AES256_GCM,data:zewoJ2YFYu3Q+TWfjfIBXLPIov1h829ISijl38Ea8p2HZSaTdGUwbXq8Ul6mPWiBYH6wE1R4c2iD2gUnAWtFIcYtLWAHENgP3ws5npVx7kw3oqwz/L95uob6hNrOFetqvhSN8BgsRbL01+lHh8LOCsbwdqBa5u+XBAHhV2dopw4=,iv:nmvtwp47sLL/haaSZr4PV3TpKiCqQZXyq9nF3USlSiY=,tag:qPtR1+Sd6rQVSDhfufLvtw==,type:str]
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
version: 3.13.1
|
version: 3.13.1
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ KIS adapter mapping should start from the locally cached official sample reposit
|
||||||
- Prefer JSON for initial adapter mapping because it is readable, diffable, and close to the KIS REST payload shape.
|
- Prefer JSON for initial adapter mapping because it is readable, diffable, and close to the KIS REST payload shape.
|
||||||
- Consider SQLite only after the fixture corpus is large enough to need indexed lookup or query-based comparison.
|
- Consider SQLite only after the fixture corpus is large enough to need indexed lookup or query-based comparison.
|
||||||
|
|
||||||
Provider credentials are not required for mock tests. When live KIS smoke tests are added later, credentials should be supplied at runtime through 1Password CLI or a later secret-provider implementation. Do not use checked-in `.env` files for provider secrets.
|
Provider credentials are not required for mock tests. When live KIS smoke tests are added later, KIS agent runtime secrets should come from SOPS + age and be injected only for the command lifetime.
|
||||||
|
|
||||||
## Redis Usage Guidelines
|
## Redis Usage Guidelines
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Command alt-worker-data-check runs the Korea daily data foundation smoke: it
|
// Command alt-worker-data-check runs the Korea daily data foundation smoke: it
|
||||||
// imports an embedded KIS daily-chart fixture through the normalize/import
|
// imports an embedded KIS daily-chart fixture through the normalize/import
|
||||||
// pipeline into an in-memory store and prints a stable summary of the bars
|
// pipeline into an in-memory store and prints a stable summary of the bars
|
||||||
// queried back. It needs no KIS credential, 1Password, or PostgreSQL, so an
|
// queried back. It needs no KIS credential, secret store, or PostgreSQL, so an
|
||||||
// operator can re-run it locally to confirm normalized daily bars are queryable.
|
// operator can re-run it locally to confirm normalized daily bars are queryable.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ import (
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
||||||
"git.toki-labs.com/toki/alt/services/worker/internal/config"
|
"git.toki-labs.com/toki/alt/services/worker/internal/config"
|
||||||
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
|
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
|
||||||
|
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
|
||||||
|
"git.toki-labs.com/toki/alt/services/worker/internal/providers/kis"
|
||||||
"git.toki-labs.com/toki/alt/services/worker/internal/socket"
|
"git.toki-labs.com/toki/alt/services/worker/internal/socket"
|
||||||
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres"
|
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres"
|
||||||
)
|
)
|
||||||
|
|
@ -34,6 +37,8 @@ func main() {
|
||||||
} else {
|
} else {
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
store := postgres.NewStore(pool)
|
store := postgres.NewStore(pool)
|
||||||
|
kisProvider := kis.NewLiveProvider(kis.NewClient(kis.ConfigFromEnv()))
|
||||||
|
jobs.RegisterDailyBarImportHandler(runner, market.ProviderKIS, importer.New(kisProvider, store))
|
||||||
deps = socket.Deps{
|
deps = socket.Deps{
|
||||||
Analysis: store,
|
Analysis: store,
|
||||||
Results: store,
|
Results: store,
|
||||||
|
|
|
||||||
53
services/worker/internal/backtest/bar_source.go
Normal file
53
services/worker/internal/backtest/bar_source.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
||||||
|
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StorageBarSource adapts worker market-data storage ports to the backtest
|
||||||
|
// engine's market/timeframe/date-range input boundary.
|
||||||
|
type StorageBarSource struct {
|
||||||
|
instruments storage.InstrumentStore
|
||||||
|
bars storage.BarStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStorageBarSource(instruments storage.InstrumentStore, bars storage.BarStore) *StorageBarSource {
|
||||||
|
return &StorageBarSource{instruments: instruments, bars: bars}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
|
||||||
|
if s == nil || s.instruments == nil || s.bars == nil {
|
||||||
|
return nil, fmt.Errorf("backtest storage bar source: storage is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
instruments, err := s.instruments.ListInstruments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("backtest storage bar source: list instruments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []market.Bar
|
||||||
|
for _, inst := range instruments {
|
||||||
|
if inst.Market != mkt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bars, err := s.bars.GetBars(ctx, inst.ID, timeframe, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("backtest storage bar source: get bars for %q: %w", inst.ID, err)
|
||||||
|
}
|
||||||
|
out = append(out, bars...)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Timestamp.Equal(out[j].Timestamp) {
|
||||||
|
return out[i].InstrumentID < out[j].InstrumentID
|
||||||
|
}
|
||||||
|
return out[i].Timestamp.Before(out[j].Timestamp)
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
97
services/worker/internal/backtest/bar_source_test.go
Normal file
97
services/worker/internal/backtest/bar_source_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceStore struct {
|
||||||
|
instruments []market.Instrument
|
||||||
|
bars map[market.InstrumentID][]market.Bar
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sourceStore) UpsertInstrument(_ context.Context, inst market.Instrument) error {
|
||||||
|
s.instruments = append(s.instruments, inst)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sourceStore) GetInstrument(_ context.Context, id market.InstrumentID) (market.Instrument, error) {
|
||||||
|
for _, inst := range s.instruments {
|
||||||
|
if inst.ID == id {
|
||||||
|
return inst, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return market.Instrument{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sourceStore) ListInstruments(_ context.Context) ([]market.Instrument, error) {
|
||||||
|
out := make([]market.Instrument, len(s.instruments))
|
||||||
|
copy(out, s.instruments)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sourceStore) UpsertBar(_ context.Context, bar market.Bar) error {
|
||||||
|
if s.bars == nil {
|
||||||
|
s.bars = make(map[market.InstrumentID][]market.Bar)
|
||||||
|
}
|
||||||
|
s.bars[bar.InstrumentID] = append(s.bars[bar.InstrumentID], bar)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sourceStore) GetBars(_ context.Context, id market.InstrumentID, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
|
||||||
|
var out []market.Bar
|
||||||
|
for _, bar := range s.bars[id] {
|
||||||
|
if bar.Timeframe != timeframe {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bar.Timestamp.Before(from) || bar.Timestamp.After(to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, bar)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorageBarSourceReadsMarketBars(t *testing.T) {
|
||||||
|
store := &sourceStore{}
|
||||||
|
krx := market.Instrument{ID: "KRX:005930", Market: market.MarketKR, Venue: market.VenueKRX, Currency: market.CurrencyKRW}
|
||||||
|
nasdaq := market.Instrument{ID: "NASDAQ:AAPL", Market: market.MarketUS, Venue: market.VenueNASDAQ, Currency: market.CurrencyUSD}
|
||||||
|
if err := store.UpsertInstrument(context.Background(), krx); err != nil {
|
||||||
|
t.Fatalf("upsert krx instrument: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.UpsertInstrument(context.Background(), nasdaq); err != nil {
|
||||||
|
t.Fatalf("upsert us instrument: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
day1 := time.Date(2024, 5, 27, 0, 0, 0, 0, time.UTC)
|
||||||
|
day2 := time.Date(2024, 5, 28, 0, 0, 0, 0, time.UTC)
|
||||||
|
if err := store.UpsertBar(context.Background(), market.Bar{InstrumentID: krx.ID, Timeframe: market.TimeframeDaily, Timestamp: day2}); err != nil {
|
||||||
|
t.Fatalf("upsert day2: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.UpsertBar(context.Background(), market.Bar{InstrumentID: krx.ID, Timeframe: market.TimeframeDaily, Timestamp: day1}); err != nil {
|
||||||
|
t.Fatalf("upsert day1: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.UpsertBar(context.Background(), market.Bar{InstrumentID: nasdaq.ID, Timeframe: market.TimeframeDaily, Timestamp: day1}); err != nil {
|
||||||
|
t.Fatalf("upsert us day1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := NewStorageBarSource(store, store)
|
||||||
|
got, err := source.GetBars(context.Background(), market.MarketKR, market.TimeframeDaily, day1, day2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get bars: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("bars: got %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Timestamp != day1 || got[1].Timestamp != day2 {
|
||||||
|
t.Fatalf("bars are not sorted by timestamp: %+v", got)
|
||||||
|
}
|
||||||
|
for _, bar := range got {
|
||||||
|
if bar.InstrumentID != krx.ID {
|
||||||
|
t.Fatalf("unexpected instrument id: %q", bar.InstrumentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// the real decode/normalize and import pipeline into an in-memory store, then
|
// the real decode/normalize and import pipeline into an in-memory store, then
|
||||||
// queries the stored bars back and prints a stable one-line summary. It proves
|
// queries the stored bars back and prints a stable one-line summary. It proves
|
||||||
// normalized daily bars are queryable in the shape the backtest milestone will
|
// normalized daily bars are queryable in the shape the backtest milestone will
|
||||||
// consume, without any live KIS credential, 1Password, or PostgreSQL.
|
// consume, without any live KIS credential, secret store, or PostgreSQL.
|
||||||
package datacheck
|
package datacheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import (
|
||||||
"git.toki-labs.com/toki/alt/packages/domain/market"
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DailyItemChartPricePath = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||||
|
DailyItemChartPriceTRID = "FHKST03010100"
|
||||||
|
)
|
||||||
|
|
||||||
// seoulOffset is the fixed KST offset. KRX has no DST, so a fixed +09:00 zone is
|
// seoulOffset is the fixed KST offset. KRX has no DST, so a fixed +09:00 zone is
|
||||||
// deterministic and avoids depending on tzdata being present at runtime.
|
// deterministic and avoids depending on tzdata being present at runtime.
|
||||||
const seoulOffsetSeconds = 9 * 60 * 60
|
const seoulOffsetSeconds = 9 * 60 * 60
|
||||||
|
|
|
||||||
465
services/worker/internal/providers/kis/live_client.go
Normal file
465
services/worker/internal/providers/kis/live_client.go
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
package kis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
||||||
|
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RealBaseURL = "https://openapi.koreainvestment.com:9443"
|
||||||
|
PaperBaseURL = "https://openapivts.koreainvestment.com:29443"
|
||||||
|
|
||||||
|
tokenPath = "/oauth2/tokenP"
|
||||||
|
|
||||||
|
DefaultUserAgent = "ALT worker KIS smoke"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Environment string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EnvironmentReal Environment = "real"
|
||||||
|
EnvironmentPaper Environment = "paper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrorUnavailable ErrorKind = "unavailable"
|
||||||
|
ErrorAuth ErrorKind = "auth"
|
||||||
|
ErrorQuota ErrorKind = "quota"
|
||||||
|
ErrorMalformed ErrorKind = "malformed"
|
||||||
|
ErrorProvider ErrorKind = "provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Kind ErrorKind
|
||||||
|
Op string
|
||||||
|
StatusCode int
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
var b strings.Builder
|
||||||
|
if e.Op != "" {
|
||||||
|
b.WriteString("kis ")
|
||||||
|
b.WriteString(e.Op)
|
||||||
|
b.WriteString(": ")
|
||||||
|
}
|
||||||
|
b.WriteString(string(e.Kind))
|
||||||
|
if e.StatusCode != 0 {
|
||||||
|
fmt.Fprintf(&b, " status=%d", e.StatusCode)
|
||||||
|
}
|
||||||
|
if e.Code != "" {
|
||||||
|
fmt.Fprintf(&b, " code=%s", e.Code)
|
||||||
|
}
|
||||||
|
if e.Message != "" {
|
||||||
|
fmt.Fprintf(&b, " msg=%s", e.Message)
|
||||||
|
}
|
||||||
|
if e.Err != nil {
|
||||||
|
fmt.Fprintf(&b, ": %v", e.Err)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsErrorKind(err error, kind ErrorKind) bool {
|
||||||
|
var kisErr *Error
|
||||||
|
return errors.As(err, &kisErr) && kisErr.Kind == kind
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Environment Environment
|
||||||
|
ConfigError string
|
||||||
|
BaseURL string
|
||||||
|
AppKey string
|
||||||
|
AppSecret string
|
||||||
|
AccountNo string
|
||||||
|
AccountProductCode string
|
||||||
|
UserAgent string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFromEnv() Config {
|
||||||
|
env := Environment(strings.TrimSpace(os.Getenv("KIS_ACTIVE_ENV")))
|
||||||
|
switch env {
|
||||||
|
case EnvironmentReal:
|
||||||
|
return Config{
|
||||||
|
Environment: EnvironmentReal,
|
||||||
|
BaseURL: RealBaseURL,
|
||||||
|
AppKey: os.Getenv("KIS_REAL_APP_KEY"),
|
||||||
|
AppSecret: os.Getenv("KIS_REAL_APP_SECRET"),
|
||||||
|
AccountNo: os.Getenv("KIS_REAL_CANO"),
|
||||||
|
AccountProductCode: os.Getenv("KIS_REAL_ACNT_PRDT_CD"),
|
||||||
|
}
|
||||||
|
case EnvironmentPaper:
|
||||||
|
return Config{
|
||||||
|
Environment: EnvironmentPaper,
|
||||||
|
BaseURL: PaperBaseURL,
|
||||||
|
AppKey: os.Getenv("KIS_PAPER_APP_KEY"),
|
||||||
|
AppSecret: os.Getenv("KIS_PAPER_APP_SECRET"),
|
||||||
|
AccountNo: os.Getenv("KIS_PAPER_CANO"),
|
||||||
|
AccountProductCode: os.Getenv("KIS_PAPER_ACNT_PRDT_CD"),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Config{
|
||||||
|
Environment: env,
|
||||||
|
ConfigError: `KIS_ACTIVE_ENV must be "paper" or "real"`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg Config) *Client {
|
||||||
|
if cfg.UserAgent == "" {
|
||||||
|
cfg.UserAgent = DefaultUserAgent
|
||||||
|
}
|
||||||
|
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
||||||
|
httpClient := cfg.HTTPClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, httpClient: httpClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
AccessToken string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Auth(ctx context.Context) (Token, error) {
|
||||||
|
if err := c.validateBase("auth"); err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]string{
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"appkey": c.cfg.AppKey,
|
||||||
|
"appsecret": c.cfg.AppSecret,
|
||||||
|
}
|
||||||
|
body, status, err := c.doJSON(ctx, http.MethodPost, tokenPath, nil, payload, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return Token{}, c.httpStatusError("auth", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresAt string `json:"access_token_token_expired"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Err: err}
|
||||||
|
}
|
||||||
|
if resp.AccessToken == "" {
|
||||||
|
return Token{}, &Error{Kind: ErrorAuth, Op: "auth", Message: "missing access_token"}
|
||||||
|
}
|
||||||
|
expiresAt, err := time.ParseInLocation("2006-01-02 15:04:05", resp.ExpiresAt, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, &Error{Kind: ErrorMalformed, Op: "auth", Message: "invalid token expiry", Err: err}
|
||||||
|
}
|
||||||
|
return Token{AccessToken: resp.AccessToken, ExpiresAt: expiresAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyItemChartPriceQuery struct {
|
||||||
|
MarketDivCode string
|
||||||
|
Symbol string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
PeriodDivCode string
|
||||||
|
AdjustedPrice string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q DailyItemChartPriceQuery) withDefaults() DailyItemChartPriceQuery {
|
||||||
|
if q.MarketDivCode == "" {
|
||||||
|
q.MarketDivCode = "J"
|
||||||
|
}
|
||||||
|
if q.PeriodDivCode == "" {
|
||||||
|
q.PeriodDivCode = "D"
|
||||||
|
}
|
||||||
|
if q.AdjustedPrice == "" {
|
||||||
|
q.AdjustedPrice = "0"
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q DailyItemChartPriceQuery) validate() error {
|
||||||
|
if q.Symbol == "" {
|
||||||
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "symbol is required"}
|
||||||
|
}
|
||||||
|
if !isYYYYMMDD(q.From) {
|
||||||
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be YYYYMMDD"}
|
||||||
|
}
|
||||||
|
if !isYYYYMMDD(q.To) {
|
||||||
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "to must be YYYYMMDD"}
|
||||||
|
}
|
||||||
|
if q.From > q.To {
|
||||||
|
return &Error{Kind: ErrorMalformed, Op: "daily", Message: "from must be before or equal to to"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchDailyItemChartPrice(ctx context.Context, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) {
|
||||||
|
token, err := c.Auth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DailyItemChartPriceResponse{}, err
|
||||||
|
}
|
||||||
|
return c.InquireDailyItemChartPrice(ctx, token.AccessToken, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) InquireDailyItemChartPrice(ctx context.Context, accessToken string, query DailyItemChartPriceQuery) (DailyItemChartPriceResponse, error) {
|
||||||
|
if err := c.validateBase("daily"); err != nil {
|
||||||
|
return DailyItemChartPriceResponse{}, err
|
||||||
|
}
|
||||||
|
if accessToken == "" {
|
||||||
|
return DailyItemChartPriceResponse{}, &Error{Kind: ErrorAuth, Op: "daily", Message: "missing access token"}
|
||||||
|
}
|
||||||
|
query = query.withDefaults()
|
||||||
|
if err := query.validate(); err != nil {
|
||||||
|
return DailyItemChartPriceResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"authorization": "Bearer " + accessToken,
|
||||||
|
"appkey": c.cfg.AppKey,
|
||||||
|
"appsecret": c.cfg.AppSecret,
|
||||||
|
"tr_id": DailyItemChartPriceTRID,
|
||||||
|
"custtype": "P",
|
||||||
|
"tr_cont": "",
|
||||||
|
}
|
||||||
|
params := url.Values{
|
||||||
|
"FID_COND_MRKT_DIV_CODE": []string{query.MarketDivCode},
|
||||||
|
"FID_INPUT_ISCD": []string{query.Symbol},
|
||||||
|
"FID_INPUT_DATE_1": []string{query.From},
|
||||||
|
"FID_INPUT_DATE_2": []string{query.To},
|
||||||
|
"FID_PERIOD_DIV_CODE": []string{query.PeriodDivCode},
|
||||||
|
"FID_ORG_ADJ_PRC": []string{query.AdjustedPrice},
|
||||||
|
}
|
||||||
|
body, status, err := c.doJSON(ctx, http.MethodGet, DailyItemChartPricePath, headers, nil, params)
|
||||||
|
if err != nil {
|
||||||
|
return DailyItemChartPriceResponse{}, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return DailyItemChartPriceResponse{}, c.httpStatusError("daily", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp DailyItemChartPriceResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Err: err}
|
||||||
|
}
|
||||||
|
if resp.ReturnCode != "0" {
|
||||||
|
return DailyItemChartPriceResponse{}, providerPayloadError("daily", resp.MessageCd, resp.Message)
|
||||||
|
}
|
||||||
|
if len(resp.Output2) == 0 {
|
||||||
|
return DailyItemChartPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "daily", Message: "no output2 rows"}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveProvider struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLiveProvider(client *Client) *LiveProvider {
|
||||||
|
return &LiveProvider{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LiveProvider) FetchDailyBars(ctx context.Context, request importer.DailyBarRequest) ([]importer.InstrumentBars, error) {
|
||||||
|
if p == nil || p.client == nil {
|
||||||
|
return nil, &Error{Kind: ErrorUnavailable, Op: "provider", Message: "KIS client is not configured"}
|
||||||
|
}
|
||||||
|
if request.Provider != "" && request.Provider != market.ProviderKIS {
|
||||||
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported provider %q", request.Provider)}
|
||||||
|
}
|
||||||
|
if len(request.Selector.Symbols) == 0 {
|
||||||
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "selector symbols are required"}
|
||||||
|
}
|
||||||
|
if request.From.IsZero() || request.To.IsZero() {
|
||||||
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "from/to date range is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
from := request.From.Format(kisDateLayout)
|
||||||
|
to := request.To.Format(kisDateLayout)
|
||||||
|
items := make([]importer.InstrumentBars, 0, len(request.Selector.Symbols))
|
||||||
|
for _, symbol := range request.Selector.Symbols {
|
||||||
|
resp, err := p.client.FetchDailyItemChartPrice(ctx, DailyItemChartPriceQuery{
|
||||||
|
Symbol: symbol,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inst := instrumentFromDailyResponse(resp, symbol)
|
||||||
|
bars, err := NormalizeDailyBars(resp, inst)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "normalize daily bars", Err: err}
|
||||||
|
}
|
||||||
|
items = append(items, importer.InstrumentBars{Instrument: inst, Bars: bars})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func instrumentFromDailyResponse(resp DailyItemChartPriceResponse, requestedSymbol string) market.Instrument {
|
||||||
|
symbol := resp.Output1.ShortCode
|
||||||
|
if symbol == "" {
|
||||||
|
symbol = requestedSymbol
|
||||||
|
}
|
||||||
|
return market.Instrument{
|
||||||
|
ID: market.InstrumentID("KRX:" + symbol),
|
||||||
|
Market: market.MarketKR,
|
||||||
|
Venue: market.VenueKRX,
|
||||||
|
Symbol: symbol,
|
||||||
|
Name: resp.Output1.Name,
|
||||||
|
Currency: market.CurrencyKRW,
|
||||||
|
ProviderSymbols: map[string]string{
|
||||||
|
string(market.ProviderKIS): symbol,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) validateBase(op string) error {
|
||||||
|
if c.cfg.ConfigError != "" {
|
||||||
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: c.cfg.ConfigError}
|
||||||
|
}
|
||||||
|
if c.cfg.BaseURL == "" {
|
||||||
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: "base URL is not configured"}
|
||||||
|
}
|
||||||
|
if c.cfg.AppKey == "" {
|
||||||
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: "app key is not configured"}
|
||||||
|
}
|
||||||
|
if c.cfg.AppSecret == "" {
|
||||||
|
return &Error{Kind: ErrorUnavailable, Op: op, Message: "app secret is not configured"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doJSON(ctx context.Context, method, path string, headers map[string]string, payload map[string]string, params url.Values) ([]byte, int, error) {
|
||||||
|
reqURL := c.cfg.BaseURL + path
|
||||||
|
if len(params) > 0 {
|
||||||
|
reqURL += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
var body io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err}
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, &Error{Kind: ErrorMalformed, Op: strings.ToLower(method), Err: err}
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", c.cfg.UserAgent)
|
||||||
|
for key, value := range headers {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp.StatusCode, &Error{Kind: ErrorUnavailable, Op: strings.ToLower(method), Err: err}
|
||||||
|
}
|
||||||
|
return data, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) httpStatusError(op string, status int, body []byte) error {
|
||||||
|
message, code := responseMessage(body)
|
||||||
|
return &Error{
|
||||||
|
Kind: classifyFailure(status, code, message),
|
||||||
|
Op: op,
|
||||||
|
StatusCode: status,
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerPayloadError(op, code, message string) error {
|
||||||
|
return &Error{
|
||||||
|
Kind: classifyFailure(http.StatusOK, code, message),
|
||||||
|
Op: op,
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseMessage(body []byte) (string, string) {
|
||||||
|
var payload struct {
|
||||||
|
Code string `json:"msg_cd"`
|
||||||
|
Message string `json:"msg1"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err == nil && (payload.Code != "" || payload.Message != "") {
|
||||||
|
return payload.Message, payload.Code
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(string(body))
|
||||||
|
if len(trimmed) > 300 {
|
||||||
|
trimmed = trimmed[:300]
|
||||||
|
}
|
||||||
|
return trimmed, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyFailure(status int, code, message string) ErrorKind {
|
||||||
|
if status == http.StatusTooManyRequests {
|
||||||
|
return ErrorQuota
|
||||||
|
}
|
||||||
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||||
|
return ErrorAuth
|
||||||
|
}
|
||||||
|
text := strings.ToLower(code + " " + message)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(text, "token"),
|
||||||
|
strings.Contains(text, "auth"),
|
||||||
|
strings.Contains(text, "oauth"),
|
||||||
|
strings.Contains(text, "인증"):
|
||||||
|
return ErrorAuth
|
||||||
|
case strings.Contains(text, "quota"),
|
||||||
|
strings.Contains(text, "rate"),
|
||||||
|
strings.Contains(text, "exceed"),
|
||||||
|
strings.Contains(text, "초당"),
|
||||||
|
strings.Contains(text, "과다"):
|
||||||
|
return ErrorQuota
|
||||||
|
default:
|
||||||
|
return ErrorProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isYYYYMMDD(value string) bool {
|
||||||
|
if len(value) != 8 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
443
services/worker/internal/providers/kis/live_client_test.go
Normal file
443
services/worker/internal/providers/kis/live_client_test.go
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
package kis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
||||||
|
workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest"
|
||||||
|
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
|
||||||
|
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthRequestsTokenAndParsesExpiry(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != tokenPath {
|
||||||
|
t.Fatalf("unexpected path: got %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("unexpected method: got %q", r.Method)
|
||||||
|
}
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode auth payload: %v", err)
|
||||||
|
}
|
||||||
|
if payload["grant_type"] != "client_credentials" {
|
||||||
|
t.Errorf("grant_type: got %q", payload["grant_type"])
|
||||||
|
}
|
||||||
|
if payload["appkey"] != "app-key" || payload["appsecret"] != "app-secret" {
|
||||||
|
t.Errorf("auth payload did not carry configured credentials")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"token-value","access_token_token_expired":"2026-06-04 12:34:56"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
token, err := client.Auth(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if token.AccessToken != "token-value" {
|
||||||
|
t.Errorf("access token: got %q", token.AccessToken)
|
||||||
|
}
|
||||||
|
if token.ExpiresAt.IsZero() {
|
||||||
|
t.Error("expected parsed expiry, got zero time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthUnavailableWhenCredentialsAreMissing(t *testing.T) {
|
||||||
|
client := NewClient(Config{BaseURL: "https://example.invalid", AppKey: "", AppSecret: ""})
|
||||||
|
_, err := client.Auth(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unavailable error, got nil")
|
||||||
|
}
|
||||||
|
if !IsErrorKind(err, ErrorUnavailable) {
|
||||||
|
t.Fatalf("error kind: got %v, want %s", err, ErrorUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigFromEnvSelectsExplicitEnvironment(t *testing.T) {
|
||||||
|
t.Run("paper", func(t *testing.T) {
|
||||||
|
t.Setenv("KIS_ACTIVE_ENV", "paper")
|
||||||
|
t.Setenv("KIS_PAPER_APP_KEY", "paper-key")
|
||||||
|
t.Setenv("KIS_PAPER_APP_SECRET", "paper-secret")
|
||||||
|
t.Setenv("KIS_PAPER_CANO", "paper-cano")
|
||||||
|
t.Setenv("KIS_PAPER_ACNT_PRDT_CD", "01")
|
||||||
|
|
||||||
|
cfg := ConfigFromEnv()
|
||||||
|
if cfg.ConfigError != "" {
|
||||||
|
t.Fatalf("config error: %s", cfg.ConfigError)
|
||||||
|
}
|
||||||
|
if cfg.Environment != EnvironmentPaper {
|
||||||
|
t.Fatalf("environment: got %q, want %q", cfg.Environment, EnvironmentPaper)
|
||||||
|
}
|
||||||
|
if cfg.BaseURL != PaperBaseURL || cfg.AppKey != "paper-key" || cfg.AppSecret != "paper-secret" {
|
||||||
|
t.Fatalf("paper config: got %+v", cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("real", func(t *testing.T) {
|
||||||
|
t.Setenv("KIS_ACTIVE_ENV", "real")
|
||||||
|
t.Setenv("KIS_REAL_APP_KEY", "real-key")
|
||||||
|
t.Setenv("KIS_REAL_APP_SECRET", "real-secret")
|
||||||
|
t.Setenv("KIS_REAL_CANO", "real-cano")
|
||||||
|
t.Setenv("KIS_REAL_ACNT_PRDT_CD", "01")
|
||||||
|
|
||||||
|
cfg := ConfigFromEnv()
|
||||||
|
if cfg.ConfigError != "" {
|
||||||
|
t.Fatalf("config error: %s", cfg.ConfigError)
|
||||||
|
}
|
||||||
|
if cfg.Environment != EnvironmentReal {
|
||||||
|
t.Fatalf("environment: got %q, want %q", cfg.Environment, EnvironmentReal)
|
||||||
|
}
|
||||||
|
if cfg.BaseURL != RealBaseURL || cfg.AppKey != "real-key" || cfg.AppSecret != "real-secret" {
|
||||||
|
t.Fatalf("real config: got %+v", cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigFromEnvRejectsMissingOrUnknownEnvironment(t *testing.T) {
|
||||||
|
for _, activeEnv := range []string{"", "papre"} {
|
||||||
|
t.Run("env="+activeEnv, func(t *testing.T) {
|
||||||
|
t.Setenv("KIS_ACTIVE_ENV", activeEnv)
|
||||||
|
client := NewClient(ConfigFromEnv())
|
||||||
|
_, err := client.Auth(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unavailable error, got nil")
|
||||||
|
}
|
||||||
|
if !IsErrorKind(err, ErrorUnavailable) {
|
||||||
|
t.Fatalf("error kind: got %v, want %s", err, ErrorUnavailable)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "KIS_ACTIVE_ENV") {
|
||||||
|
t.Fatalf("error should mention KIS_ACTIVE_ENV: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInquireDailyItemChartPriceSendsHeadersAndDecodes(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != DailyItemChartPricePath {
|
||||||
|
t.Fatalf("unexpected path: got %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("authorization"); got != "Bearer access-token" {
|
||||||
|
t.Errorf("authorization header: got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("tr_id"); got != DailyItemChartPriceTRID {
|
||||||
|
t.Errorf("tr_id header: got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("custtype"); got != "P" {
|
||||||
|
t.Errorf("custtype header: got %q", got)
|
||||||
|
}
|
||||||
|
query := r.URL.Query()
|
||||||
|
wantParams := map[string]string{
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": "005930",
|
||||||
|
"FID_INPUT_DATE_1": "20240527",
|
||||||
|
"FID_INPUT_DATE_2": "20240528",
|
||||||
|
"FID_PERIOD_DIV_CODE": "D",
|
||||||
|
"FID_ORG_ADJ_PRC": "0",
|
||||||
|
}
|
||||||
|
for key, want := range wantParams {
|
||||||
|
if got := query.Get(key); got != want {
|
||||||
|
t.Errorf("query %s: got %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
resp, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
|
||||||
|
Symbol: "005930",
|
||||||
|
From: "20240527",
|
||||||
|
To: "20240528",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("daily query: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Output1.ShortCode != "005930" {
|
||||||
|
t.Errorf("short code: got %q", resp.Output1.ShortCode)
|
||||||
|
}
|
||||||
|
if len(resp.Output2) != 2 {
|
||||||
|
t.Errorf("bar rows: got %d, want 2", len(resp.Output2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInquireDailyItemChartPriceClassifiesQuotaAndMalformed(t *testing.T) {
|
||||||
|
t.Run("quota", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
_, _ = w.Write([]byte(`{"msg_cd":"RATE_LIMIT","msg1":"too many requests"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
_, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
|
||||||
|
Symbol: "005930",
|
||||||
|
From: "20240527",
|
||||||
|
To: "20240528",
|
||||||
|
})
|
||||||
|
if err == nil || !IsErrorKind(err, ErrorQuota) {
|
||||||
|
t.Fatalf("error kind: got %v, want %s", err, ErrorQuota)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
_, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
|
||||||
|
Symbol: "005930",
|
||||||
|
From: "20240527",
|
||||||
|
To: "20240528",
|
||||||
|
})
|
||||||
|
if err == nil || !IsErrorKind(err, ErrorMalformed) {
|
||||||
|
t.Fatalf("error kind: got %v, want %s", err, ErrorMalformed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInquireDailyItemChartPriceDoesNotExposeSecretsInError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`{"msg_cd":"AUTH","msg1":"invalid credential"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
_, err := client.InquireDailyItemChartPrice(context.Background(), "access-token", DailyItemChartPriceQuery{
|
||||||
|
Symbol: "005930",
|
||||||
|
From: "20240527",
|
||||||
|
To: "20240528",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
text := err.Error()
|
||||||
|
for _, secret := range []string{"app-key", "app-secret", "access-token"} {
|
||||||
|
if strings.Contains(text, secret) {
|
||||||
|
t.Fatalf("error exposed secret %q: %s", secret, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveProviderFetchesAndNormalizesDailyBars(t *testing.T) {
|
||||||
|
var authCalls, dailyCalls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case tokenPath:
|
||||||
|
authCalls++
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
|
||||||
|
case DailyItemChartPricePath:
|
||||||
|
dailyCalls++
|
||||||
|
if got := r.URL.Query().Get("FID_INPUT_ISCD"); got != "005930" {
|
||||||
|
t.Errorf("symbol query: got %q", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json"))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
provider := NewLiveProvider(client)
|
||||||
|
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
|
||||||
|
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{
|
||||||
|
Kind: market.UniverseSelectorWatchlist,
|
||||||
|
Venue: market.VenueKRX,
|
||||||
|
Symbols: []string{"005930"},
|
||||||
|
},
|
||||||
|
From: time.Date(2024, 5, 27, 0, 0, 0, 0, seoul),
|
||||||
|
To: time.Date(2024, 5, 28, 0, 0, 0, 0, seoul),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch daily bars: %v", err)
|
||||||
|
}
|
||||||
|
if authCalls != 1 || dailyCalls != 1 {
|
||||||
|
t.Fatalf("calls: auth=%d daily=%d, want 1 each", authCalls, dailyCalls)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items: got %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Instrument.ID != market.InstrumentID("KRX:005930") {
|
||||||
|
t.Errorf("instrument id: got %q", items[0].Instrument.ID)
|
||||||
|
}
|
||||||
|
if got := items[0].Instrument.ProviderSymbols[string(market.ProviderKIS)]; got != "005930" {
|
||||||
|
t.Errorf("provider symbol: got %q", got)
|
||||||
|
}
|
||||||
|
if len(items[0].Bars) != 2 {
|
||||||
|
t.Fatalf("bars: got %d, want 2", len(items[0].Bars))
|
||||||
|
}
|
||||||
|
if items[0].Bars[0].InstrumentID != items[0].Instrument.ID {
|
||||||
|
t.Errorf("bar instrument id: got %q, want %q", items[0].Bars[0].InstrumentID, items[0].Instrument.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveProviderRejectsMissingRange(t *testing.T) {
|
||||||
|
provider := NewLiveProvider(NewClient(Config{BaseURL: "https://example.invalid", AppKey: "app-key", AppSecret: "app-secret"}))
|
||||||
|
_, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{Symbols: []string{"005930"}},
|
||||||
|
})
|
||||||
|
if err == nil || !IsErrorKind(err, ErrorMalformed) {
|
||||||
|
t.Fatalf("error kind: got %v, want %s", err, ErrorMalformed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type importStore struct {
|
||||||
|
instruments map[market.InstrumentID]market.Instrument
|
||||||
|
bars map[importBarKey]market.Bar
|
||||||
|
}
|
||||||
|
|
||||||
|
type importBarKey struct {
|
||||||
|
instrumentID market.InstrumentID
|
||||||
|
timeframe market.Timeframe
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newImportStore() *importStore {
|
||||||
|
return &importStore{
|
||||||
|
instruments: make(map[market.InstrumentID]market.Instrument),
|
||||||
|
bars: make(map[importBarKey]market.Bar),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importStore) UpsertInstrument(_ context.Context, inst market.Instrument) error {
|
||||||
|
s.instruments[inst.ID] = inst
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importStore) GetInstrument(_ context.Context, id market.InstrumentID) (market.Instrument, error) {
|
||||||
|
inst, ok := s.instruments[id]
|
||||||
|
if !ok {
|
||||||
|
return market.Instrument{}, storage.ErrInstrumentNotFound
|
||||||
|
}
|
||||||
|
return inst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importStore) ListInstruments(_ context.Context) ([]market.Instrument, error) {
|
||||||
|
out := make([]market.Instrument, 0, len(s.instruments))
|
||||||
|
for _, inst := range s.instruments {
|
||||||
|
out = append(out, inst)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importStore) UpsertBar(_ context.Context, bar market.Bar) error {
|
||||||
|
s.bars[importBarKey{bar.InstrumentID, bar.Timeframe, bar.Timestamp}] = bar
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *importStore) GetBars(_ context.Context, id market.InstrumentID, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
|
||||||
|
var out []market.Bar
|
||||||
|
for key, bar := range s.bars {
|
||||||
|
if key.instrumentID != id || key.timeframe != timeframe {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key.timestamp.Before(from) || key.timestamp.After(to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, bar)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveProviderImportFeedsBacktestBarSource(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case tokenPath:
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
|
||||||
|
case DailyItemChartPricePath:
|
||||||
|
_, _ = w.Write(readFixture(t, "daily_itemchartprice_response.sample.json"))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
provider := NewLiveProvider(client)
|
||||||
|
store := newImportStore()
|
||||||
|
imp := importer.New(provider, store)
|
||||||
|
|
||||||
|
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
|
||||||
|
from := time.Date(2024, 5, 27, 0, 0, 0, 0, seoul)
|
||||||
|
to := time.Date(2024, 5, 28, 0, 0, 0, 0, seoul)
|
||||||
|
result, err := imp.ImportDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{
|
||||||
|
Kind: market.UniverseSelectorWatchlist,
|
||||||
|
Venue: market.VenueKRX,
|
||||||
|
Symbols: []string{"005930"},
|
||||||
|
},
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import daily bars: %v", err)
|
||||||
|
}
|
||||||
|
if result.Instruments != 1 || result.Bars != 2 {
|
||||||
|
t.Fatalf("import result: got %+v, want 1 instrument and 2 bars", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := workerbacktest.NewStorageBarSource(store, store)
|
||||||
|
bars, err := source.GetBars(context.Background(), market.MarketKR, market.TimeframeDaily, from, to)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backtest bar source query: %v", err)
|
||||||
|
}
|
||||||
|
if len(bars) != 2 {
|
||||||
|
t.Fatalf("query bars: got %d, want 2", len(bars))
|
||||||
|
}
|
||||||
|
if bars[0].InstrumentID != market.InstrumentID("KRX:005930") {
|
||||||
|
t.Fatalf("bar instrument id: got %q", bars[0].InstrumentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveProviderPaperSmoke(t *testing.T) {
|
||||||
|
if os.Getenv("KIS_LIVE_SMOKE") != "1" {
|
||||||
|
t.Skip("set KIS_LIVE_SMOKE=1 with SOPS-injected KIS paper env to run live smoke")
|
||||||
|
}
|
||||||
|
if os.Getenv("KIS_ACTIVE_ENV") != "paper" {
|
||||||
|
t.Fatal("KIS_ACTIVE_ENV must be paper for this live smoke")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewLiveProvider(NewClient(ConfigFromEnv()))
|
||||||
|
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
|
||||||
|
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{
|
||||||
|
Kind: market.UniverseSelectorWatchlist,
|
||||||
|
Venue: market.VenueKRX,
|
||||||
|
Symbols: []string{"005930"},
|
||||||
|
},
|
||||||
|
From: time.Date(2024, 5, 27, 0, 0, 0, 0, seoul),
|
||||||
|
To: time.Date(2024, 5, 28, 0, 0, 0, 0, seoul),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("paper live provider smoke: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items: got %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Instrument.ID != market.InstrumentID("KRX:005930") {
|
||||||
|
t.Fatalf("instrument id: got %q", items[0].Instrument.ID)
|
||||||
|
}
|
||||||
|
if len(items[0].Bars) == 0 {
|
||||||
|
t.Fatal("expected at least one live bar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,6 @@ Before committing fixtures, remove:
|
||||||
- account numbers or personal identifiers
|
- account numbers or personal identifiers
|
||||||
- live authorization headers
|
- live authorization headers
|
||||||
- local hostnames or machine-specific paths
|
- local hostnames or machine-specific paths
|
||||||
- 1Password secret references or item names
|
- secret-store item references
|
||||||
|
|
||||||
Runtime credentials are not required for mock tests. Later live KIS smoke tests should inject credentials at runtime through 1Password CLI or a later secret-provider implementation.
|
Runtime credentials are not required for mock tests. Later live KIS smoke tests should inject credentials at runtime from SOPS-decrypted environment variables only.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue