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:
toki 2026-06-03 12:10:48 +09:00
parent 2071c6fba9
commit 8c3c4ba5a2
21 changed files with 1542 additions and 223 deletions

11
.gitignore vendored
View file

@ -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/

View file

@ -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

View file

@ -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`

View file

@ -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 화면을 구현한다.

View file

@ -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 또는 동등한 화면 정의 산출물이 준비되었고 사용자가 승인했다.
## 범위 ## 범위

View file

@ -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
View 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
View 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)
' "$@"

View file

@ -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 입력 조회 경계에서 사용 가능함을 확인했다.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View 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
}

View 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)
}
}
}

View file

@ -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 (

View file

@ -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

View 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
}

View 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")
}
}

View file

@ -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.