diff --git a/.gitignore b/.gitignore index bd647e7..2717407 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index d3f5b90..69f2391 100644 --- a/README.md +++ b/README.md @@ -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 -- ` 형태로 필요한 process에만 값을 주입하고, 자동화 환경에서는 개인 계정이 아닌 1Password service account를 사용한다. +ALT의 KIS provider credential은 실제 provider 호출 단계에서 SOPS + age를 agent runtime secret의 단일 원본으로 둔다. 현재 mock/fixture 테스트 단계에서는 KIS credential이 필요하지 않다. 실제 KIS smoke가 필요해지면 `bin/kis-sops-env ` 또는 배포 환경의 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 diff --git a/agent-roadmap/current.md b/agent-roadmap/current.md index 27e8723..bdec2f0 100644 --- a/agent-roadmap/current.md +++ b/agent-roadmap/current.md @@ -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` diff --git a/agent-roadmap/phase/operator-surface/PHASE.md b/agent-roadmap/phase/operator-surface/PHASE.md index 6f7bb06..d15defd 100644 --- a/agent-roadmap/phase/operator-surface/PHASE.md +++ b/agent-roadmap/phase/operator-surface/PHASE.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 화면을 구현한다. diff --git a/agent-roadmap/phase/operator-surface/milestones/flutter-operator-console-mvp.md b/agent-roadmap/phase/operator-surface/milestones/flutter-operator-console-mvp.md index 4df5d06..6c5e73a 100644 --- a/agent-roadmap/phase/operator-surface/milestones/flutter-operator-console-mvp.md +++ b/agent-roadmap/phase/operator-surface/milestones/flutter-operator-console-mvp.md @@ -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 또는 동등한 화면 정의 산출물이 준비되었고 사용자가 승인했다. ## 범위 diff --git a/agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md b/agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md index 0443498..4dd7abc 100644 --- a/agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md +++ b/agent-roadmap/phase/operator-surface/milestones/kis-live-data-collection-pipeline.md @@ -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 +- 확인 필요: 없음 diff --git a/bin/kis-paper-daily-smoke b/bin/kis-paper-daily-smoke new file mode 100755 index 0000000..7ff9569 --- /dev/null +++ b/bin/kis-paper-daily-smoke @@ -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) diff --git a/bin/kis-sops-env b/bin/kis-sops-env new file mode 100755 index 0000000..baa6c9f --- /dev/null +++ b/bin/kis-sops-env @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -eq 0 ]]; then + echo "usage: bin/kis-sops-env [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) +' "$@" diff --git a/docs/kis-live-secret-guide.md b/docs/kis-live-secret-guide.md index a31026f..54f33f7 100644 --- a/docs/kis-live-secret-guide.md +++ b/docs/kis-live-secret-guide.md @@ -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에 출력되지 않았다. diff --git a/docs/kis-live-secret-handoff.md b/docs/kis-live-secret-handoff.md index c8627e5..2324833 100644 --- a/docs/kis-live-secret-handoff.md +++ b/docs/kis-live-secret-handoff.md @@ -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: ~ +- 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. diff --git a/secrets/kis.live.sops.yaml b/secrets/kis.live.sops.yaml index 03e22bd..2c2fa0a 100644 --- a/secrets/kis.live.sops.yaml +++ b/secrets/kis.live.sops.yaml @@ -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 diff --git a/services/worker/README.md b/services/worker/README.md index 73e3640..3fd7e39 100644 --- a/services/worker/README.md +++ b/services/worker/README.md @@ -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 diff --git a/services/worker/cmd/alt-worker-data-check/main.go b/services/worker/cmd/alt-worker-data-check/main.go index 7280ee1..6492d80 100644 --- a/services/worker/cmd/alt-worker-data-check/main.go +++ b/services/worker/cmd/alt-worker-data-check/main.go @@ -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 diff --git a/services/worker/cmd/alt-worker/main.go b/services/worker/cmd/alt-worker/main.go index 47ba28f..f007849 100644 --- a/services/worker/cmd/alt-worker/main.go +++ b/services/worker/cmd/alt-worker/main.go @@ -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, diff --git a/services/worker/internal/backtest/bar_source.go b/services/worker/internal/backtest/bar_source.go new file mode 100644 index 0000000..764a2fd --- /dev/null +++ b/services/worker/internal/backtest/bar_source.go @@ -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 +} diff --git a/services/worker/internal/backtest/bar_source_test.go b/services/worker/internal/backtest/bar_source_test.go new file mode 100644 index 0000000..8e7ad0f --- /dev/null +++ b/services/worker/internal/backtest/bar_source_test.go @@ -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) + } + } +} diff --git a/services/worker/internal/marketdata/datacheck/datacheck.go b/services/worker/internal/marketdata/datacheck/datacheck.go index 26b64e2..95c11f4 100644 --- a/services/worker/internal/marketdata/datacheck/datacheck.go +++ b/services/worker/internal/marketdata/datacheck/datacheck.go @@ -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 ( diff --git a/services/worker/internal/providers/kis/daily_itemchartprice.go b/services/worker/internal/providers/kis/daily_itemchartprice.go index 53ebed5..b70cc4c 100644 --- a/services/worker/internal/providers/kis/daily_itemchartprice.go +++ b/services/worker/internal/providers/kis/daily_itemchartprice.go @@ -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 diff --git a/services/worker/internal/providers/kis/live_client.go b/services/worker/internal/providers/kis/live_client.go new file mode 100644 index 0000000..b3dbc35 --- /dev/null +++ b/services/worker/internal/providers/kis/live_client.go @@ -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 +} diff --git a/services/worker/internal/providers/kis/live_client_test.go b/services/worker/internal/providers/kis/live_client_test.go new file mode 100644 index 0000000..628c839 --- /dev/null +++ b/services/worker/internal/providers/kis/live_client_test.go @@ -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") + } +} diff --git a/services/worker/testdata/providers/kis/README.md b/services/worker/testdata/providers/kis/README.md index 966fce0..418f4d0 100644 --- a/services/worker/testdata/providers/kis/README.md +++ b/services/worker/testdata/providers/kis/README.md @@ -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.