From 6524afaa20a335edde72bf5d7c4fae8c0363d88d Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 29 May 2026 19:54:28 +0900 Subject: [PATCH] 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 --- .../CODE_REVIEW-cloud-G07.md | 109 ------- .../01_provider_foundation/PLAN-cloud-G07.md | 184 ------------ go.work.sum | 27 ++ packages/domain/market/types.go | 20 ++ packages/domain/market/types_test.go | 43 +++ .../providers/kis/daily_itemchartprice.go | 112 +++++++ .../kis/daily_itemchartprice_test.go | 273 ++++++++++++++++++ 7 files changed, 475 insertions(+), 293 deletions(-) delete mode 100644 agent-task/m-korea-daily-data-foundation/01_provider_foundation/CODE_REVIEW-cloud-G07.md delete mode 100644 agent-task/m-korea-daily-data-foundation/01_provider_foundation/PLAN-cloud-G07.md create mode 100644 packages/domain/market/types_test.go create mode 100644 services/worker/internal/providers/kis/daily_itemchartprice.go create mode 100644 services/worker/internal/providers/kis/daily_itemchartprice_test.go diff --git a/agent-task/m-korea-daily-data-foundation/01_provider_foundation/CODE_REVIEW-cloud-G07.md b/agent-task/m-korea-daily-data-foundation/01_provider_foundation/CODE_REVIEW-cloud-G07.md deleted file mode 100644 index 4fd63e9..0000000 --- a/agent-task/m-korea-daily-data-foundation/01_provider_foundation/CODE_REVIEW-cloud-G07.md +++ /dev/null @@ -1,109 +0,0 @@ - - -# 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-`이면 완료 이벤트 메타데이터를 보고하고 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?** diff --git a/agent-task/m-korea-daily-data-foundation/01_provider_foundation/PLAN-cloud-G07.md b/agent-task/m-korea-daily-data-foundation/01_provider_foundation/PLAN-cloud-G07.md deleted file mode 100644 index b916fb1..0000000 --- a/agent-task/m-korea-daily-data-foundation/01_provider_foundation/PLAN-cloud-G07.md +++ /dev/null @@ -1,184 +0,0 @@ - - -# 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:`을 사용한다. - -수정 파일 및 체크리스트: - -- [ ] `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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/go.work.sum b/go.work.sum index e587130..8a46ff0 100644 --- a/go.work.sum +++ b/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= diff --git a/packages/domain/market/types.go b/packages/domain/market/types.go index c39ba57..c3fec8d 100644 --- a/packages/domain/market/types.go +++ b/packages/domain/market/types.go @@ -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 } diff --git a/packages/domain/market/types_test.go b/packages/domain/market/types_test.go new file mode 100644 index 0000000..54365fa --- /dev/null +++ b/packages/domain/market/types_test.go @@ -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)) + } +} diff --git a/services/worker/internal/providers/kis/daily_itemchartprice.go b/services/worker/internal/providers/kis/daily_itemchartprice.go new file mode 100644 index 0000000..53ebed5 --- /dev/null +++ b/services/worker/internal/providers/kis/daily_itemchartprice.go @@ -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 +} diff --git a/services/worker/internal/providers/kis/daily_itemchartprice_test.go b/services/worker/internal/providers/kis/daily_itemchartprice_test.go new file mode 100644 index 0000000..a4fe139 --- /dev/null +++ b/services/worker/internal/providers/kis/daily_itemchartprice_test.go @@ -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) + } + } +}