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:
parent
735dafe927
commit
6524afaa20
7 changed files with 475 additions and 293 deletions
|
|
@ -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?**
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
27
go.work.sum
27
go.work.sum
|
|
@ -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/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=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -44,6 +44,26 @@ type Instrument struct {
|
|||
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 {
|
||||
Value string
|
||||
}
|
||||
|
|
|
|||
43
packages/domain/market/types_test.go
Normal file
43
packages/domain/market/types_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
112
services/worker/internal/providers/kis/daily_itemchartprice.go
Normal file
112
services/worker/internal/providers/kis/daily_itemchartprice.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue