feat: korea provider implementation and data foundation

- Add types_test.go for market domain contracts
- Add worker provider package for Korea data
- Update market types and go.work.sum
- Remove deprecated 01_provider_foundation task files
This commit is contained in:
toki 2026-05-29 19:54:28 +09:00
parent 735dafe927
commit 6524afaa20
7 changed files with 475 additions and 293 deletions

View file

@ -1,109 +0,0 @@
<!-- task=m-korea-daily-data-foundation/01_provider_foundation plan=0 tag=KIS_FOUNDATION -->
# Code Review Reference - KIS_FOUNDATION
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-05-29
task=m-korea-daily-data-foundation/01_provider_foundation, plan=0, tag=KIS_FOUNDATION
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 plan skill의 code-review 절차를 따른다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [KIS_FOUNDATION-1] Domain Selector Vocabulary | [ ] |
| [KIS_FOUNDATION-2] KIS Fixture Decoder | [ ] |
| [KIS_FOUNDATION-3] KIS Row Normalization | [ ] |
## 구현 체크리스트
- [ ] `packages/domain/market`에 provider-neutral universe selector와 provider symbol vocabulary를 추가하고 unit test를 작성한다.
- [ ] `services/worker/internal/providers/kis` 또는 동등한 worker-owned provider package에 KIS daily chart fixture decoder와 normalization 함수를 추가한다.
- [ ] KIS `output2` rows를 `market.Bar`로 변환할 때 `Asia/Seoul`, `KRX`, `KRW`, `TimeframeDaily` 기본선을 테스트한다.
- [ ] fixture JSON 전체가 secret-free이고 parser test에 사용되는지 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_M.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고하고 roadmap 수정은 런타임에 맡긴다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- domain package가 worker/protobuf/storage dependency를 새로 갖지 않는지 확인한다.
- KIS fixture normalization이 KRX/KRW/Asia-Seoul 일봉 기준을 테스트로 고정하는지 확인한다.
- secret-like 값이 fixture에 들어가지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### KIS_FOUNDATION-1 중간 검증
```bash
$ cd packages/domain && go test -count=1 ./market
```
### KIS_FOUNDATION-2 중간 검증
```bash
$ cd services/worker && go test -count=1 ./internal/providers/kis
```
### KIS_FOUNDATION-3 중간 검증
```bash
$ cd services/worker && go test -count=1 ./internal/providers/kis
```
### 최종 검증
```bash
$ cd packages/domain && go test -count=1 ./...
$ cd services/worker && go test -count=1 ./internal/providers/kis
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**

View file

@ -1,184 +0,0 @@
<!-- task=m-korea-daily-data-foundation/01_provider_foundation plan=0 tag=KIS_FOUNDATION -->
# Plan - KIS Provider Foundation
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 구현의 마지막 단계다. 구현 중 사용자 결정, 외부 환경, 범위 충돌로 막히면 active review stub의 `사용자 리뷰 요청`에 정확한 근거를 남기고 멈춘다. `USER_REVIEW.md`, `complete.log`, archive 이동은 code-review 전용이다.
## 배경
Korea Daily Data Foundation은 KIS credential 없이 mock/fixture 기반으로 먼저 검증한다. 현재 domain model은 instrument와 bar만 있고 provider selector, KIS daily response normalization, KRX date/currency 기본선이 없다. 이 작업은 후속 import/storage pipeline이 소비할 안정적인 provider boundary를 만든다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active `CODE_REVIEW-cloud-G07.md``사용자 리뷰 요청` 섹션에 기록한다. code-review가 그 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/data-foundation/PHASE.md`
- `agent-roadmap/phase/data-foundation/milestones/korea-daily-data-foundation.md`
- `agent-ops/rules/project/domain/domain-model/rules.md`
- `agent-ops/rules/project/domain/worker/rules.md`
- `packages/domain/market/types.go`
- `services/worker/internal/storage/ports.go`
- `services/worker/internal/storage/postgres/mapping.go`
- `services/worker/internal/storage/postgres/mapping_test.go`
- `services/worker/testdata/providers/kis/README.md`
- `services/worker/testdata/providers/kis/daily_itemchartprice_request.sample.json`
- `services/worker/testdata/providers/kis/daily_itemchartprice_response.sample.json`
- `services/worker/testdata/providers/kis/daily_bars_normalized.expected.json`
- `.agent-cache/koreainvestment/open-trading-api/examples_llm/domestic_stock/inquire_daily_itemchartprice/inquire_daily_itemchartprice.py`
- `.agent-cache/koreainvestment/open-trading-api/examples_llm/domestic_stock/inquire_daily_itemchartprice/chk_inquire_daily_itemchartprice.py`
### 테스트 커버리지 공백
- Provider-neutral universe selector: 기존 테스트 없음. 새 domain unit test 필요.
- KIS daily chart response normalization: 기존 테스트 없음. 새 worker provider fixture test 필요.
- KRX daily timestamp/currency default: 기존 테스트 없음. 새 worker provider fixture test 필요.
### 심볼 참조
none. 이 계획은 신규 타입/패키지 추가를 우선하며 기존 심볼 rename/remove는 하지 않는다.
### 분할 판단
Split decision policy를 먼저 평가했다. 공유 foundation 없이 import/storage/job을 바로 만들면 후속 작업이 provider API를 추정하게 되므로 분할한다.
- 공통 task group: `m-korea-daily-data-foundation`
- 현재 subtask: `01_provider_foundation`, 의존 없음
- 후속 subtask: `02+01_import_storage_pipeline`, `01_provider_foundation` 완료 필요
- 후속 subtask: `03+01,02_data_check_smoke`, `01_provider_foundation``02+01_import_storage_pipeline` 완료 필요
### 범위 결정 근거
실제 KIS HTTP 호출, credential/1Password wiring, PostgreSQL migration, CLI smoke는 제외한다. 이 작업은 provider-neutral selector와 KIS mock fixture normalization까지만 닫는다.
### 빌드 등급
build=`cloud-G07`, review=`cloud-G07`. domain과 worker 경계를 새로 만들고 후속 storage 작업의 API가 되므로 cloud review가 필요하다.
## 구현 체크리스트
- [ ] `packages/domain/market`에 provider-neutral universe selector와 provider symbol vocabulary를 추가하고 unit test를 작성한다.
- [ ] `services/worker/internal/providers/kis` 또는 동등한 worker-owned provider package에 KIS daily chart fixture decoder와 normalization 함수를 추가한다.
- [ ] KIS `output2` rows를 `market.Bar`로 변환할 때 `Asia/Seoul`, `KRX`, `KRW`, `TimeframeDaily` 기본선을 테스트한다.
- [ ] fixture JSON 전체가 secret-free이고 parser test에 사용되는지 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [KIS_FOUNDATION-1] Domain Selector Vocabulary
문제: [types.go](/config/workspace/alt/packages/domain/market/types.go:37)는 `Instrument.ProviderSymbols`만 있고 watchlist, exchange-wide, sector-like selector를 표현할 타입이 없다.
해결 방법: `packages/domain/market/types.go``Provider`, `UniverseSelectorKind`, `UniverseSelector`를 추가한다. 기존 `Instrument.ProviderSymbols map[string]string`은 유지하고, 새 타입은 provider-neutral request vocabulary로만 둔다.
Before:
```go
type Instrument struct {
ID InstrumentID
Market Market
Venue Venue
Symbol string
Name string
Currency Currency
ProviderSymbols map[string]string
}
```
After:
```go
type Provider string
const ProviderKIS Provider = "kis"
type UniverseSelectorKind string
const (
UniverseSelectorWatchlist UniverseSelectorKind = "watchlist"
UniverseSelectorExchange UniverseSelectorKind = "exchange"
UniverseSelectorSector UniverseSelectorKind = "sector"
)
type UniverseSelector struct {
Kind UniverseSelectorKind
Market Market
Venue Venue
Symbols []string
Name string
}
```
수정 파일 및 체크리스트:
- [ ] `packages/domain/market/types.go`에 타입/상수 추가
- [ ] `packages/domain/market/types_test.go` 추가
테스트 작성: `TestUniverseSelectorVocabulary`를 추가해 watchlist/exchange/sector 값이 안정적인 string 값을 갖는지 확인한다.
중간 검증:
```bash
cd packages/domain && go test -count=1 ./market
```
### [KIS_FOUNDATION-2] KIS Fixture Decoder
문제: [daily_itemchartprice_response.sample.json](/config/workspace/alt/services/worker/testdata/providers/kis/daily_itemchartprice_response.sample.json:1)은 fixture로 존재하지만 이를 KIS response shape로 decode하는 worker package가 없다.
해결 방법: worker 내부에 `internal/providers/kis` package를 만들고 KIS daily item chart request/response structs, fixture loader test, response validation을 둔다.
수정 파일 및 체크리스트:
- [ ] `services/worker/internal/providers/kis/daily_itemchartprice.go` 추가
- [ ] `services/worker/internal/providers/kis/daily_itemchartprice_test.go` 추가
- [ ] fixture path는 `services/worker/testdata/providers/kis/*.json`을 사용
테스트 작성: `TestLoadDailyItemChartPriceFixture`에서 request endpoint/TR ID와 response `output1`, `output2` row count를 검증한다.
중간 검증:
```bash
cd services/worker && go test -count=1 ./internal/providers/kis
```
### [KIS_FOUNDATION-3] KIS Row Normalization
문제: KIS row의 `stck_bsop_date`, `stck_oprc`, `stck_hgpr`, `stck_lwpr`, `stck_clpr`, `acml_vol`을 ALT `market.Bar`로 바꾸는 규칙이 없다.
해결 방법: `NormalizeDailyBars`를 추가해 KIS rows를 `market.Bar`로 변환한다. 날짜는 `Asia/Seoul` 자정, currency는 `KRW`, timeframe은 `1d`, instrument id는 provider symbol metadata에서 받은 `KRX:<symbol>`을 사용한다.
수정 파일 및 체크리스트:
- [ ] `services/worker/internal/providers/kis/daily_itemchartprice.go`에 normalization 함수 추가
- [ ] `services/worker/internal/providers/kis/daily_itemchartprice_test.go`에 expected normalized fixture 대조 추가
테스트 작성: `TestNormalizeDailyItemChartPriceBars`에서 [daily_bars_normalized.expected.json](/config/workspace/alt/services/worker/testdata/providers/kis/daily_bars_normalized.expected.json:1)의 두 bar와 완전 대조한다.
중간 검증:
```bash
cd services/worker && go test -count=1 ./internal/providers/kis
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `packages/domain/market/types.go` | KIS_FOUNDATION-1 |
| `packages/domain/market/types_test.go` | KIS_FOUNDATION-1 |
| `services/worker/internal/providers/kis/daily_itemchartprice.go` | KIS_FOUNDATION-2, KIS_FOUNDATION-3 |
| `services/worker/internal/providers/kis/daily_itemchartprice_test.go` | KIS_FOUNDATION-2, KIS_FOUNDATION-3 |
## 최종 검증
```bash
cd packages/domain && go test -count=1 ./...
cd services/worker && go test -count=1 ./internal/providers/kis
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,2 +1,29 @@
cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cznic/sortutil v0.0.0-20181122101858-f5f958428db8/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
github.com/klauspost/compress v1.17.1/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
modernc.org/golex v1.1.0/go.mod h1:2pVlfqApurXhR1m0N+WDYu6Twnc4QuvO4+U8HnwoiRA=
modernc.org/parser v1.1.0/go.mod h1:CXl3OTJRZij8FeMpzI3Id/bjupHf0u9HSrCUP4Z9pbA=
modernc.org/y v1.0.9/go.mod h1:EjpZC9SxK4Fr+sF7KezoT/AKrl7MOnNO/kNrhxTeib4=

View file

@ -44,6 +44,26 @@ type Instrument struct {
ProviderSymbols map[string]string ProviderSymbols map[string]string
} }
type Provider string
const ProviderKIS Provider = "kis"
type UniverseSelectorKind string
const (
UniverseSelectorWatchlist UniverseSelectorKind = "watchlist"
UniverseSelectorExchange UniverseSelectorKind = "exchange"
UniverseSelectorSector UniverseSelectorKind = "sector"
)
type UniverseSelector struct {
Kind UniverseSelectorKind
Market Market
Venue Venue
Symbols []string
Name string
}
type Decimal struct { type Decimal struct {
Value string Value string
} }

View file

@ -0,0 +1,43 @@
package market
import "testing"
func TestUniverseSelectorVocabulary(t *testing.T) {
cases := []struct {
kind UniverseSelectorKind
want string
}{
{UniverseSelectorWatchlist, "watchlist"},
{UniverseSelectorExchange, "exchange"},
{UniverseSelectorSector, "sector"},
}
for _, c := range cases {
if string(c.kind) != c.want {
t.Errorf("selector kind %q: got %q, want %q", c.want, string(c.kind), c.want)
}
}
if string(ProviderKIS) != "kis" {
t.Errorf("ProviderKIS: got %q, want %q", string(ProviderKIS), "kis")
}
}
func TestUniverseSelectorFields(t *testing.T) {
sel := UniverseSelector{
Kind: UniverseSelectorExchange,
Market: MarketKR,
Venue: VenueKRX,
Symbols: []string{"005930", "000660"},
Name: "semiconductors",
}
if sel.Kind != UniverseSelectorExchange {
t.Errorf("Kind mismatch: got %v", sel.Kind)
}
if sel.Market != MarketKR || sel.Venue != VenueKRX {
t.Errorf("market/venue mismatch: got %v/%v", sel.Market, sel.Venue)
}
if len(sel.Symbols) != 2 {
t.Errorf("Symbols length: got %d, want 2", len(sel.Symbols))
}
}

View file

@ -0,0 +1,112 @@
// Package kis decodes and normalizes Korea Investment & Securities (KIS) daily
// chart provider payloads into ALT domain market types. It works against mock
// fixtures only; live HTTP and credential wiring belong to a later milestone.
package kis
import (
"encoding/json"
"fmt"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
// 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
// kisDateLayout matches the KIS stck_bsop_date field, e.g. "20240527".
const kisDateLayout = "20060102"
// DailyItemChartPriceRequest mirrors the KIS inquire-daily-itemchartprice
// request shape captured in the worker test fixtures.
type DailyItemChartPriceRequest struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
TrID string `json:"tr_id"`
Params map[string]string `json:"params"`
}
// DailyItemChartPriceResponse mirrors the KIS daily chart response payload.
type DailyItemChartPriceResponse struct {
ReturnCode string `json:"rt_cd"`
MessageCd string `json:"msg_cd"`
Message string `json:"msg1"`
Output1 DailyItemChartPriceSummary `json:"output1"`
Output2 []DailyItemChartPriceBarRow `json:"output2"`
}
// DailyItemChartPriceSummary is the KIS output1 instrument-level metadata block.
type DailyItemChartPriceSummary struct {
Name string `json:"hts_kor_isnm"`
ShortCode string `json:"stck_shrn_iscd"`
CurrentPrice string `json:"stck_prpr"`
AccumVolume string `json:"acml_vol"`
AccumTradeAmt string `json:"acml_tr_pbmn"`
}
// DailyItemChartPriceBarRow is a single KIS output2 OHLCV row.
type DailyItemChartPriceBarRow struct {
BusinessDate string `json:"stck_bsop_date"`
Open string `json:"stck_oprc"`
High string `json:"stck_hgpr"`
Low string `json:"stck_lwpr"`
Close string `json:"stck_clpr"`
Volume string `json:"acml_vol"`
TradeAmount string `json:"acml_tr_pbmn"`
}
// DecodeDailyItemChartPriceRequest decodes a KIS daily chart request fixture.
func DecodeDailyItemChartPriceRequest(data []byte) (DailyItemChartPriceRequest, error) {
var req DailyItemChartPriceRequest
if err := json.Unmarshal(data, &req); err != nil {
return DailyItemChartPriceRequest{}, fmt.Errorf("decode kis daily request: %w", err)
}
return req, nil
}
// DecodeDailyItemChartPriceResponse decodes a KIS daily chart response fixture
// and validates that it represents a successful payload with bar rows.
func DecodeDailyItemChartPriceResponse(data []byte) (DailyItemChartPriceResponse, error) {
var resp DailyItemChartPriceResponse
if err := json.Unmarshal(data, &resp); err != nil {
return DailyItemChartPriceResponse{}, fmt.Errorf("decode kis daily response: %w", err)
}
if resp.ReturnCode != "0" {
return DailyItemChartPriceResponse{}, fmt.Errorf("kis daily response not successful: rt_cd=%q msg=%q", resp.ReturnCode, resp.Message)
}
if len(resp.Output2) == 0 {
return DailyItemChartPriceResponse{}, fmt.Errorf("kis daily response has no output2 rows")
}
return resp, nil
}
// NormalizeDailyBars converts KIS output2 rows into ALT domain bars for the
// given instrument. KRX daily defaults are applied: Asia/Seoul midnight
// timestamps, the daily timeframe, and the instrument currency (KRW for KRX).
func NormalizeDailyBars(resp DailyItemChartPriceResponse, inst market.Instrument) ([]market.Bar, error) {
currency := inst.Currency
if currency == "" {
currency = market.CurrencyKRW
}
seoul := time.FixedZone("Asia/Seoul", seoulOffsetSeconds)
bars := make([]market.Bar, 0, len(resp.Output2))
for i, row := range resp.Output2 {
ts, err := time.ParseInLocation(kisDateLayout, row.BusinessDate, seoul)
if err != nil {
return nil, fmt.Errorf("parse business date for row %d: %w", i, err)
}
bars = append(bars, market.Bar{
InstrumentID: inst.ID,
Timeframe: market.TimeframeDaily,
Timestamp: ts,
Open: market.Price{Currency: currency, Amount: market.Decimal{Value: row.Open}},
High: market.Price{Currency: currency, Amount: market.Decimal{Value: row.High}},
Low: market.Price{Currency: currency, Amount: market.Decimal{Value: row.Low}},
Close: market.Price{Currency: currency, Amount: market.Decimal{Value: row.Close}},
Volume: market.Quantity{Amount: market.Decimal{Value: row.Volume}},
})
}
return bars, nil
}

View file

@ -0,0 +1,273 @@
package kis
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
const fixtureDir = "../../../testdata/providers/kis"
func readFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join(fixtureDir, name))
if err != nil {
t.Fatalf("read fixture %s: %v", name, err)
}
return data
}
func TestLoadDailyItemChartPriceFixture(t *testing.T) {
req, err := DecodeDailyItemChartPriceRequest(readFixture(t, "daily_itemchartprice_request.sample.json"))
if err != nil {
t.Fatalf("decode request: %v", err)
}
if req.Endpoint != "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" {
t.Errorf("endpoint mismatch: got %q", req.Endpoint)
}
if req.TrID != "FHKST03010100" {
t.Errorf("tr_id mismatch: got %q", req.TrID)
}
resp, err := DecodeDailyItemChartPriceResponse(readFixture(t, "daily_itemchartprice_response.sample.json"))
if err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Output1.ShortCode != "005930" {
t.Errorf("output1 short code mismatch: got %q", resp.Output1.ShortCode)
}
if len(resp.Output2) != 2 {
t.Fatalf("output2 row count: got %d, want 2", len(resp.Output2))
}
}
// expectedDailyBarsFixture is a test-only view of
// daily_bars_normalized.expected.json. It is the contract that
// NormalizeDailyBars must reproduce; keeping it here (not in the production
// package) avoids widening the provider API for test concerns.
type expectedDailyBarsFixture struct {
Instrument struct {
ID string `json:"id"`
Market string `json:"market"`
Venue string `json:"venue"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Currency string `json:"currency"`
ProviderSymbols map[string]string `json:"provider_symbols"`
} `json:"instrument"`
Bars []struct {
InstrumentID string `json:"instrument_id"`
Timeframe string `json:"timeframe"`
Timestamp string `json:"timestamp"`
Open string `json:"open"`
High string `json:"high"`
Low string `json:"low"`
Close string `json:"close"`
Volume string `json:"volume"`
Currency string `json:"currency"`
} `json:"bars"`
}
func decodeExpectedDailyBarsFixture(t *testing.T) expectedDailyBarsFixture {
t.Helper()
var fx expectedDailyBarsFixture
if err := json.Unmarshal(readFixture(t, "daily_bars_normalized.expected.json"), &fx); err != nil {
t.Fatalf("decode expected bars fixture: %v", err)
}
return fx
}
func (fx expectedDailyBarsFixture) instrument() market.Instrument {
return market.Instrument{
ID: market.InstrumentID(fx.Instrument.ID),
Market: market.Market(fx.Instrument.Market),
Venue: market.Venue(fx.Instrument.Venue),
Symbol: fx.Instrument.Symbol,
Name: fx.Instrument.Name,
Currency: market.Currency(fx.Instrument.Currency),
ProviderSymbols: fx.Instrument.ProviderSymbols,
}
}
func (fx expectedDailyBarsFixture) bars(t *testing.T) []market.Bar {
t.Helper()
bars := make([]market.Bar, 0, len(fx.Bars))
for i, b := range fx.Bars {
ts, err := time.Parse(time.RFC3339, b.Timestamp)
if err != nil {
t.Fatalf("parse expected timestamp[%d] %q: %v", i, b.Timestamp, err)
}
cur := market.Currency(b.Currency)
bars = append(bars, market.Bar{
InstrumentID: market.InstrumentID(b.InstrumentID),
Timeframe: market.Timeframe(b.Timeframe),
Timestamp: ts,
Open: market.Price{Currency: cur, Amount: market.Decimal{Value: b.Open}},
High: market.Price{Currency: cur, Amount: market.Decimal{Value: b.High}},
Low: market.Price{Currency: cur, Amount: market.Decimal{Value: b.Low}},
Close: market.Price{Currency: cur, Amount: market.Decimal{Value: b.Close}},
Volume: market.Quantity{Amount: market.Decimal{Value: b.Volume}},
})
}
return bars
}
func TestNormalizeDailyItemChartPriceBars(t *testing.T) {
resp, err := DecodeDailyItemChartPriceResponse(readFixture(t, "daily_itemchartprice_response.sample.json"))
if err != nil {
t.Fatalf("decode response: %v", err)
}
expected := decodeExpectedDailyBarsFixture(t)
inst := expected.instrument()
// Lock the KRX daily defaults the fixture encodes before normalizing.
if inst.ID != market.InstrumentID("KRX:005930") {
t.Errorf("fixture instrument id: got %q, want %q", inst.ID, "KRX:005930")
}
if inst.Currency != market.CurrencyKRW {
t.Errorf("fixture instrument currency: got %q, want %q", inst.Currency, market.CurrencyKRW)
}
if inst.Venue != market.VenueKRX {
t.Errorf("fixture instrument venue: got %q, want %q", inst.Venue, market.VenueKRX)
}
if got := inst.ProviderSymbols[string(market.ProviderKIS)]; got != "005930" {
t.Errorf("fixture kis provider symbol: got %q, want %q", got, "005930")
}
bars, err := NormalizeDailyBars(resp, inst)
if err != nil {
t.Fatalf("normalize bars: %v", err)
}
want := expected.bars(t)
if len(bars) != len(want) {
t.Fatalf("bar count: got %d, want %d", len(bars), len(want))
}
for i := range want {
assertBarEqual(t, i, bars[i], want[i])
}
}
func assertBarEqual(t *testing.T, idx int, got, want market.Bar) {
t.Helper()
if got.InstrumentID != want.InstrumentID {
t.Errorf("bar[%d] instrument id: got %q, want %q", idx, got.InstrumentID, want.InstrumentID)
}
if got.Timeframe != want.Timeframe {
t.Errorf("bar[%d] timeframe: got %q, want %q", idx, got.Timeframe, want.Timeframe)
}
if !got.Timestamp.Equal(want.Timestamp) {
t.Errorf("bar[%d] timestamp: got %s, want %s", idx, got.Timestamp.Format(time.RFC3339), want.Timestamp.Format(time.RFC3339))
}
// Lock the +09:00 wall-clock representation, not just the instant.
if got.Timestamp.Format(time.RFC3339) != want.Timestamp.Format(time.RFC3339) {
t.Errorf("bar[%d] timestamp offset: got %s, want %s", idx, got.Timestamp.Format(time.RFC3339), want.Timestamp.Format(time.RFC3339))
}
assertPrice(t, idx, "open", got.Open, want.Open)
assertPrice(t, idx, "high", got.High, want.High)
assertPrice(t, idx, "low", got.Low, want.Low)
assertPrice(t, idx, "close", got.Close, want.Close)
if got.Volume.Amount.Value != want.Volume.Amount.Value {
t.Errorf("bar[%d] volume: got %q, want %q", idx, got.Volume.Amount.Value, want.Volume.Amount.Value)
}
}
func assertPrice(t *testing.T, idx int, field string, got, want market.Price) {
t.Helper()
if got.Currency != want.Currency {
t.Errorf("bar[%d] %s currency: got %q, want %q", idx, field, got.Currency, want.Currency)
}
if got.Amount.Value != want.Amount.Value {
t.Errorf("bar[%d] %s amount: got %q, want %q", idx, field, got.Amount.Value, want.Amount.Value)
}
}
// providerSymbolsFixture is a test-only view of provider_symbols.sample.json. It
// asserts that the selector vocabulary added to packages/domain/market stays in
// sync with the fixture that downstream universe/import work will consume.
type providerSymbolsFixture struct {
Provider string `json:"provider"`
Selectors []struct {
Kind string `json:"kind"`
Venue string `json:"venue"`
Name string `json:"name"`
Symbols []string `json:"symbols"`
} `json:"selectors"`
Instruments []struct {
ID string `json:"id"`
Market string `json:"market"`
Venue string `json:"venue"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Currency string `json:"currency"`
ProviderSymbols map[string]string `json:"provider_symbols"`
} `json:"instruments"`
}
func TestProviderSymbolsFixtureVocabulary(t *testing.T) {
var fx providerSymbolsFixture
if err := json.Unmarshal(readFixture(t, "provider_symbols.sample.json"), &fx); err != nil {
t.Fatalf("decode provider symbols fixture: %v", err)
}
if market.Provider(fx.Provider) != market.ProviderKIS {
t.Errorf("provider: got %q, want %q", fx.Provider, market.ProviderKIS)
}
wantKinds := map[market.UniverseSelectorKind]bool{
market.UniverseSelectorWatchlist: false,
market.UniverseSelectorExchange: false,
market.UniverseSelectorSector: false,
}
for i, sel := range fx.Selectors {
kind := market.UniverseSelectorKind(sel.Kind)
if _, ok := wantKinds[kind]; !ok {
t.Errorf("selector[%d] unexpected kind %q", i, sel.Kind)
continue
}
wantKinds[kind] = true
if len(sel.Symbols) == 0 {
t.Errorf("selector[%d] %q has no symbols", i, sel.Kind)
}
switch kind {
case market.UniverseSelectorExchange:
if market.Venue(sel.Venue) != market.VenueKRX {
t.Errorf("exchange selector venue: got %q, want %q", sel.Venue, market.VenueKRX)
}
case market.UniverseSelectorSector:
if sel.Name == "" {
t.Errorf("sector selector missing name")
}
}
}
for kind, seen := range wantKinds {
if !seen {
t.Errorf("selector kind %q missing from fixture", kind)
}
}
if len(fx.Instruments) == 0 {
t.Fatal("provider symbols fixture has no instruments")
}
for i, inst := range fx.Instruments {
if market.Venue(inst.Venue) != market.VenueKRX {
t.Errorf("instrument[%d] venue: got %q, want %q", i, inst.Venue, market.VenueKRX)
}
if market.Currency(inst.Currency) != market.CurrencyKRW {
t.Errorf("instrument[%d] currency: got %q, want %q", i, inst.Currency, market.CurrencyKRW)
}
sym, ok := inst.ProviderSymbols[string(market.ProviderKIS)]
if !ok || sym == "" {
t.Errorf("instrument[%d] missing kis provider symbol", i)
continue
}
if sym != inst.Symbol {
t.Errorf("instrument[%d] kis provider symbol %q != symbol %q", i, sym, inst.Symbol)
}
}
}