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
|
||||
*.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/
|
||||
.build/
|
||||
coverage/
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ AI agent는 작업 전에 루트 지침과 관련 domain rule을 먼저 확인
|
|||
- protobuf generated output은 source schema에서 생성하고 손으로 편집하지 않는다.
|
||||
- 새 Go module을 추가하면 `go.work`에 등록한다.
|
||||
- 로컬 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로 둔다.
|
||||
|
||||
## 환경 변수
|
||||
|
|
@ -119,9 +119,9 @@ AI agent는 작업 전에 루트 지침과 관련 domain rule을 먼저 확인
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
## 활성 Milestone
|
||||
|
||||
- [계획] KIS Live Data Collection Pipeline
|
||||
- [검토중] KIS Live Data Collection Pipeline
|
||||
- Phase: `agent-roadmap/phase/operator-surface/PHASE.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 호출을 검증했다.
|
||||
- [계획] KIS Live Data Collection Pipeline
|
||||
- 경로: `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
|
||||
- 경로: `agent-roadmap/phase/operator-surface/milestones/flutter-operator-console-mvp.md`
|
||||
- 요약: headless 운영 검증과 화면 wireframe 승인 뒤에만 Flutter에서 market data, backtest run, result를 조회하고 실행 요청할 수 있는 MVP 화면을 구현한다.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
- 결정 필요: 아래 체크리스트
|
||||
- [x] `Operator Headless Workflow Validation`이 완료되어 화면 없이 대표 운영 흐름을 검증했다.
|
||||
- [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 또는 동등한 화면 정의 산출물이 준비되었고 사용자가 승인했다.
|
||||
|
||||
## 범위
|
||||
|
|
|
|||
|
|
@ -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 문서에 기록하지 않는다.
|
||||
- [ ] live smoke 대상 범위를 정한다: 국내주식 일봉 `inquire-daily-itemchartprice` 단일 symbol/date range부터 시작한다.
|
||||
- [ ] live 호출 결과를 commit 가능한 sanitized fixture/evidence로 남기는 기준을 정한다.
|
||||
- [x] KIS live smoke에 사용할 credential 주입 방식을 정한다. 원문 secret, secret-store 항목 이름, 계좌번호는 tracked 문서에 기록하지 않는다. 기준: SOPS + age 단일 원본, `bin/kis-sops-env` command-lifetime env 주입.
|
||||
- [x] live smoke 대상 범위를 정한다: 국내주식 일봉 `inquire-daily-itemchartprice` 단일 symbol/date range부터 시작한다. 기준: paper `005930`, `20240527~20240528`.
|
||||
- [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 상태 분류
|
||||
- live response를 sanitized fixture/evidence로 남기는 절차
|
||||
- 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
|
||||
|
||||
## 기능
|
||||
|
|
@ -38,23 +38,23 @@ Flutter 실화면 구현 전에 한국투자증권(KIS) API를 실제 provider
|
|||
|
||||
fixture/mock 기반 adapter를 실제 KIS provider 호출과 worker import pipeline으로 확장한다.
|
||||
|
||||
- [ ] [credential-boundary] KIS credential과 token을 runtime-only secret으로 주입한다. 검증: tracked 파일에 secret, item/vault 이름, 계좌번호가 남지 않고, 미설정 시 명확한 unavailable 상태가 나온다.
|
||||
- [ ] [auth-client] KIS auth/token client가 국내주식 일봉 호출에 필요한 access token을 얻고 만료/오류 상태를 분류한다. 검증: fake auth response unit test와 credential 없는 unavailable test가 통과한다.
|
||||
- [ ] [daily-rest-client] KIS `inquire-daily-itemchartprice` REST client가 request parameter, response decode, error/quota/malformed 상태를 처리한다. 검증: 로컬 KIS 공식 샘플 cache와 sanitized fixture 기반 test가 통과한다.
|
||||
- [ ] [live-provider] worker market data importer가 KIS live provider를 선택해 일봉 데이터를 fetch할 수 있다. 검증: fake HTTP/live-gated smoke가 provider request, normalized bars, storage upsert를 확인한다.
|
||||
- [ ] [live-smoke] credential이 준비된 환경에서 단일 symbol/date range KIS live smoke를 실행하고 sanitized evidence를 남긴다. 검증: 실제 KIS 호출 결과가 raw secret 없이 저장/조회되고, 실패 시 auth/quota/unavailable 원인이 grep 가능한 상태로 남는다.
|
||||
- [ ] [backtest-input] KIS live로 수집한 normalized daily bars가 backtest 입력 조회 경계에서 사용 가능하다. 검증: 저장된 bars를 backtest fixture/input query가 읽을 수 있음을 smoke 또는 test로 확인한다.
|
||||
- [ ] [operator-handoff] Flutter Operator Console MVP로 넘길 provider readiness와 market data status key를 정리한다. 검증: 실행 명령, secret 주입 전제, sanitized evidence 경로, unavailable/error 상태를 문서화한다.
|
||||
- [x] [credential-boundary] KIS credential은 command lifetime 동안만 주입하고, access token은 runtime에서 발급한다. 검증: SOPS + age 단일 원본과 `bin/kis-sops-env` command-lifetime env 주입을 사용하고, tracked 파일에 raw secret, secret-store 항목 이름, 계좌번호를 기록하지 않는다.
|
||||
- [x] [auth-client] KIS auth/token client가 국내주식 일봉 호출에 필요한 access token을 얻고 만료/오류 상태를 분류한다. 검증: fake auth response unit test, credential 없는 unavailable test, SOPS 주입 live-gated paper smoke가 통과한다.
|
||||
- [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가 통과한다.
|
||||
- [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를 등록한다.
|
||||
- [x] [live-smoke] credential이 준비된 환경에서 단일 symbol/date range KIS live smoke를 실행하고 sanitized evidence를 남긴다. 검증: 실제 KIS paper 호출 결과를 raw secret 없이 요약 evidence로 기록하고, 실패 시 auth/quota/unavailable 원인이 grep 가능한 상태로 남는다.
|
||||
- [x] [backtest-input] KIS live로 수집한 normalized daily bars가 backtest 입력 조회 adapter 경계에서 사용 가능하다. 검증: `StorageBarSource`와 KIS fake HTTP -> importer -> store -> `StorageBarSource` query test가 통과한다.
|
||||
- [x] [operator-handoff] Flutter Operator Console MVP로 넘길 provider readiness와 market data status key를 정리한다. 검증: 실행 명령, secret 주입 전제, sanitized evidence key, unavailable/auth/quota/malformed/provider 상태를 문서화한다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 아직 live KIS provider pipeline과 credential-gated smoke evidence가 없다.
|
||||
- 상태: 요청됨
|
||||
- 요청일: 2026-06-03
|
||||
- 완료 근거: SOPS 주입 기반 KIS paper 일봉 smoke, worker KIS auth/daily/live-provider, import/storage/`StorageBarSource` 입력 조회 evidence가 준비됐다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
- 리뷰 코멘트: 모든 기능 Task는 완료 후보이며, 사용자 최종 확인 전까지 활성 마일스톤으로 유지한다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
|
|
@ -64,19 +64,20 @@ fixture/mock 기반 adapter를 실제 KIS provider 호출과 worker import pipel
|
|||
- 실거래 주문 API
|
||||
- 계좌/잔고/주문 체결 조회
|
||||
- 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/`
|
||||
- 표준선(선택): 기존 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` 경계를 따른다.
|
||||
- 표준선(선택): 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를 실행한다.
|
||||
- 표준선(선택): Vaultwarden 접속 경로, host 경로, age key 위치 같은 환경 세부는 private testing rule에만 두고 tracked roadmap에는 raw secret, 계좌번호, item/vault 이름을 기록하지 않는다.
|
||||
- 표준선(선택): live smoke evidence는 데이터 적재 정책이 아니라 테스트 검증 기준이다. Commit 가능한 evidence에는 raw KIS payload/header/token/key/account를 남기지 않고, 실행 환경, symbol/date range, bar count, date span, message code, result 같은 요약만 둔다.
|
||||
- 표준선(선택): 원격 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
|
||||
- 후속 작업: 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 smoke를 준비할 때 사용자가 어떤 값을 어디에 넣고, 에이전트에게 무엇만 알려주면 되는지 정리한다. secret 원문, 계좌번호, 비밀번호, Vaultwarden item 이름, vault 이름은 이 문서와 task/roadmap/log에 기록하지 않는다.
|
||||
한국투자증권 Open API 기반 quant agent가 사용할 장기 비밀값은 `SOPS + age`로 관리한다.
|
||||
|
||||
## 원칙
|
||||
## 1. repo 위치로 이동
|
||||
|
||||
- Vaultwarden은 원격 host의 code-server와 동레벨 container로 둔다.
|
||||
- `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로 이동한다.
|
||||
원격 host의 ALT repo에서만 진행한다.
|
||||
|
||||
```bash
|
||||
cd /Users/toki/docker/services/code-server/data/volume/workspace/alt
|
||||
```
|
||||
|
||||
아래 명령으로 암호화된 파일을 연다.
|
||||
## 2. SOPS 파일 열기
|
||||
|
||||
```bash
|
||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||
EDITOR="${EDITOR:-nano}" \
|
||||
sops secrets/kis.live.sops.yaml
|
||||
```
|
||||
|
||||
편집기가 열리면 아래 키들의 오른쪽 값을 채운다.
|
||||
## 3. 템플릿 붙여넣기
|
||||
|
||||
2번 명령으로 열린 편집기 안에서 `secrets/kis.live.sops.yaml`의 전체 내용을 아래 형태로 맞춘다. 각 값 설명을 실제 값으로 바꾼 뒤 저장하고 편집기를 종료한다.
|
||||
|
||||
```yaml
|
||||
KIS_APP_KEY: "..."
|
||||
KIS_APP_SECRET: "..."
|
||||
KIS_BASE_URL: "..."
|
||||
KIS_IS_PAPER: "true"
|
||||
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"
|
||||
```
|
||||
|
||||
입력 기준:
|
||||
국내주식 종합계좌 기준 `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[...]` 형태로 보이면 정상이다.
|
||||
아직 값을 넣지 않은 빈 placeholder는 `""`로 보일 수 있다. 실제 credential을 넣은 뒤에도 값이 평문으로 보이면 저장이 잘못된 것이다.
|
||||
`nano` 기준 저장은 `Ctrl+O`, `Enter`, 종료는 `Ctrl+X`다.
|
||||
|
||||
```bash
|
||||
sed -n '1,80p' secrets/kis.live.sops.yaml
|
||||
```
|
||||
## 4. 입력 확인
|
||||
|
||||
복호화 확인이 필요하면 원격 host에서만 아래 명령을 실행한다. 출력에는 secret 원문이 나오므로 화면 공유, 로그 저장, 채팅 복사를 하지 않는다.
|
||||
값을 출력하지 않고 필요한 key가 모두 들어왔는지만 확인한다.
|
||||
|
||||
```bash
|
||||
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 원문을 알려주지 않는다. 아래 메타 정보만 알려준다.
|
||||
|
||||
```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 값을 직접 입력한다.
|
||||
모의테스트 command는 `KIS_ACTIVE_ENV=paper`를 일반 환경변수로 주고, secret은 SOPS에서 주입한다.
|
||||
|
||||
```bash
|
||||
KIS_ACTIVE_ENV=paper \
|
||||
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
|
||||
KIS_ACTIVE_ENV=paper \
|
||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||
sops exec-env secrets/kis.live.sops.yaml -- \
|
||||
docker exec \
|
||||
-e KIS_APP_KEY \
|
||||
-e KIS_APP_SECRET \
|
||||
-e KIS_BASE_URL \
|
||||
-e KIS_IS_PAPER \
|
||||
code-server bash -lc 'cd /config/workspace/alt && bin/kis-live-smoke'
|
||||
bin/kis-sops-env bin/kis-paper-daily-smoke \
|
||||
--symbol 005930 \
|
||||
--start 20240527 \
|
||||
--end 20240528
|
||||
```
|
||||
|
||||
## 완료 조건
|
||||
## 6. 확인 체크리스트
|
||||
|
||||
KIS live smoke가 완료되었다고 보려면 다음 evidence가 필요하다.
|
||||
|
||||
- 실제 KIS 일봉 조회가 credential 준비 환경에서 실행되었다.
|
||||
- raw secret 없이 sanitized response 또는 요약 evidence가 남았다.
|
||||
- 실패 시 auth, quota, unavailable, malformed response 중 어떤 상태인지 grep 가능한 출력으로 남았다.
|
||||
- 수집된 normalized daily bars가 worker import/storage 경계를 통과했다.
|
||||
- 저장된 bars가 backtest 입력 조회 경계에서 사용 가능함을 확인했다.
|
||||
- [ ] SOPS에는 실전/모의 8개 값만 있다.
|
||||
- [ ] secret 값이 문서, 로그, 테스트 fixture에 출력되지 않았다.
|
||||
|
|
|
|||
|
|
@ -1,66 +1,136 @@
|
|||
# KIS Live Secret Handoff
|
||||
|
||||
## Current Setup
|
||||
## 현재 기준
|
||||
|
||||
- Remote field host owns the secret boundary.
|
||||
- Vaultwarden runs outside the code-server container as a sibling container.
|
||||
- `sops` and `age` are installed on the remote host only.
|
||||
- The remote host age recipient for KIS live smoke is:
|
||||
- KIS agent runtime secret의 단일 원본은 SOPS + age다.
|
||||
- 모의테스트도 필요하므로 `secrets/kis.live.sops.yaml`에 실전/모의 값을 분리해 저장한다.
|
||||
- `bin/kis-sops-env`는 명령 실행 동안만 SOPS 값을 환경변수로 주입한다.
|
||||
|
||||
```text
|
||||
age1fwqdkmqh3ykq7cnchcrr5nfwr77qsdt4p79lpa49g6q5k88cluvqkfw8d3
|
||||
```
|
||||
## 사용자 수행 순서
|
||||
|
||||
## Repo Files
|
||||
|
||||
- `.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:
|
||||
원격 host의 ALT repo로 이동한다.
|
||||
|
||||
```bash
|
||||
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
|
||||
KIS_APP_KEY
|
||||
KIS_APP_SECRET
|
||||
KIS_BASE_URL
|
||||
KIS_IS_PAPER
|
||||
```bash
|
||||
SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" \
|
||||
EDITOR="${EDITOR:-nano}" \
|
||||
sops secrets/kis.live.sops.yaml
|
||||
```
|
||||
|
||||
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
|
||||
SOPS file is filled.
|
||||
First smoke target:
|
||||
env=paper
|
||||
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
|
||||
- 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_APP_SECRET: ""
|
||||
KIS_BASE_URL: ""
|
||||
KIS_IS_PAPER: ENC[AES256_GCM,data:BqMakQ==,iv:AJEP2eaQeYSQfWP8BhfcDR946tSQHUT6EPWV8EE4Txc=,tag:nsjz0h9Eh2H2/lru3qwmyQ==,type:str]
|
||||
KIS_REAL_APP_KEY: ENC[AES256_GCM,data:eoKvNJIjaIzTfh84SMW9TUElBEfyvc8/cCil4pkWNQG7B7x3,iv:2Hln0LpyBujjHpWo64dQLGSMYmYq+HNLPOGzH+YR6eA=,tag:sfhN86asqnmjyHxBka7t+A==,type:str]
|
||||
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_REAL_CANO: ENC[AES256_GCM,data:OP7hLH/nlms=,iv:BoWAHHEhRW0lWtyM16KaIv5CAiQfGE51sJZ16qtZ6Zw=,tag:Pq97AvZAKexrsEQWpuJQbg==,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:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5cVJCUlNwcHcrN2lOM1g3
|
||||
U2NHL3VMdkhnQ1V6Y3NlNFdGOW5HdTlnZFdvCmtyREUySFp0VXFHUk5tQmE0L1Yz
|
||||
VTBXQUFoWWRmSDQyYlE2QTVtRzk1R3cKLS0tIDNTS3ljYjI0OFZVeWJhb1ZuTUNQ
|
||||
U2c4Z0tRNHFSVkVja2dMZnBtM3pQeEEKJbdpCZ3XAWQaRqe3GbqsdIXDEjjEuLkT
|
||||
HQUSiPEpFJcj8nunztDM6YQZS/QDJLNv2nWOxuts5AHoWaQEVAKkCw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmWW5pcnFFbUxsSXRReDJR
|
||||
K01PK0hYMDZGK1hnaHY2SWVPM1NJYWEzZ0hnCjVCTS95WWI0N3ZXc0krU1E4dHpt
|
||||
b0dvZmtzQjQ5eXNBelVGUlZtZGNPdHMKLS0tIEVuNWFGbC9hUm9EKzBsdE0wZTlm
|
||||
Ujd0OTgvRlg3S2phbWpxS3puTXYwSkEKBUgasZctfaUyWopf1CgCr7gXHFulUQe1
|
||||
NDvkGcFewWE9eVAX824Acca7YAzZcpisXJtiyfDXAQ/6J8B+3hc1SQ==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1fwqdkmqh3ykq7cnchcrr5nfwr77qsdt4p79lpa49g6q5k88cluvqkfw8d3
|
||||
lastmodified: "2026-06-02T05:31:11Z"
|
||||
mac: ENC[AES256_GCM,data:QSdh4ZoAE97c9TzQoe1baZefZ5Rk2vaRw97lKnR2h/iMRjTYbHgkP75Werd8T5oDH20q1o4IkjmOjyEk1XpHDZ9HThKsYmMI3xDFh/64Gz6rW8oCs8dEKW+j7AZ11DleGDkWHu4bIXk5HEeP2GNb7WJufpeIad0Ci3SyBGfJ+aA=,iv:5/BretO0xOrJ4o3jSSRlH0tIccM2wxaYg2TgiXj+gFk=,tag:8hxUFjbL/Q1UN74k1upYgQ==,type:str]
|
||||
lastmodified: "2026-06-03T00:15:30Z"
|
||||
mac: ENC[AES256_GCM,data:zewoJ2YFYu3Q+TWfjfIBXLPIov1h829ISijl38Ea8p2HZSaTdGUwbXq8Ul6mPWiBYH6wE1R4c2iD2gUnAWtFIcYtLWAHENgP3ws5npVx7kw3oqwz/L95uob6hNrOFetqvhSN8BgsRbL01+lHh8LOCsbwdqBa5u+XBAHhV2dopw4=,iv:nmvtwp47sLL/haaSZr4PV3TpKiCqQZXyq9nF3USlSiY=,tag:qPtR1+Sd6rQVSDhfufLvtw==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
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.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Command alt-worker-data-check runs the Korea daily data foundation smoke: it
|
||||
// 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
|
||||
// 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.
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ import (
|
|||
|
||||
"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/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/storage/postgres"
|
||||
)
|
||||
|
|
@ -34,6 +37,8 @@ func main() {
|
|||
} else {
|
||||
defer pool.Close()
|
||||
store := postgres.NewStore(pool)
|
||||
kisProvider := kis.NewLiveProvider(kis.NewClient(kis.ConfigFromEnv()))
|
||||
jobs.RegisterDailyBarImportHandler(runner, market.ProviderKIS, importer.New(kisProvider, store))
|
||||
deps = socket.Deps{
|
||||
Analysis: 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
|
||||
// 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
|
||||
// consume, without any live KIS credential, 1Password, or PostgreSQL.
|
||||
// consume, without any live KIS credential, secret store, or PostgreSQL.
|
||||
package datacheck
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import (
|
|||
"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
|
||||
// deterministic and avoids depending on tzdata being present at runtime.
|
||||
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
|
||||
- live authorization headers
|
||||
- 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