feat(marketdata): KIS 미국장 일봉 수집을 추가한다
미국장 일봉 import가 국내 KIS chart endpoint에 묶여 있으면 NASDAQ/NYSE 요청을 정상화된 USD bar로 저장할 수 없다. provider capability로 venue/timeframe 지원 여부를 먼저 검증하고, KIS overseas dailyprice 응답을 venue timezone과 USD 통화 기준으로 normalize하도록 경로를 분리한다.
This commit is contained in:
parent
f552dfc3ef
commit
f38cbbc5cb
24 changed files with 2002 additions and 148 deletions
|
|
@ -36,8 +36,8 @@
|
||||||
|
|
||||||
미국장 instrument, calendar, daily data, shared query, backtest compatibility 확장을 묶는다.
|
미국장 instrument, calendar, daily data, shared query, backtest compatibility 확장을 묶는다.
|
||||||
|
|
||||||
- [ ] [us-instruments] 미국장 instrument identity와 provider symbol mapping이 있다. 검증: watchlist fixture가 `market=us`, `venue`, `currency=USD`, `asset_type` metadata와 provider symbol을 분리해 가진다.
|
- [x] [us-instruments] 미국장 instrument identity와 provider symbol mapping이 있다. 검증: watchlist fixture가 `market=us`, `venue`, `currency=USD`, `asset_type` metadata와 provider symbol을 분리해 가진다. 근거: `agent-task/archive/2026/06/m-us-market-expansion/01_market_foundation/complete.log`
|
||||||
- [ ] [us-calendar] 미국장 timezone/currency/venue 기준이 있다. 검증: market/currency/venue 차이가 domain model과 contracts에 일관되게 반영된다.
|
- [x] [us-calendar] 미국장 timezone/currency/venue 기준이 있다. 검증: market/currency/venue 차이가 domain model과 contracts에 일관되게 반영된다. 근거: `agent-task/archive/2026/06/m-us-market-expansion/01_market_foundation/complete.log`
|
||||||
- [ ] [us-daily-bars] KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장한다. 검증: 한국장 pipeline을 깨지 않고 provider capability가 미국장 daily bar import를 허용한다.
|
- [ ] [us-daily-bars] KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장한다. 검증: 한국장 pipeline을 깨지 않고 provider capability가 미국장 daily bar import를 허용한다.
|
||||||
- [ ] [shared-query] 한국장과 미국장이 같은 query/contract pattern을 공유한다. 검증: market selector만 바꿔도 공통 query path를 사용한다.
|
- [ ] [shared-query] 한국장과 미국장이 같은 query/contract pattern을 공유한다. 검증: market selector만 바꿔도 공통 query path를 사용한다.
|
||||||
- [ ] [us-backtest] 미국장 daily data로 backtest fixture 또는 smoke가 동작한다. 검증: `bin/test`와 관련 worker 검증이 통과한다.
|
- [ ] [us-backtest] 미국장 daily data로 backtest fixture 또는 smoke가 동작한다. 검증: `bin/test`와 관련 worker 검증이 통과한다.
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
|
|
||||||
- 상태: 없음
|
- 상태: 없음
|
||||||
- 요청일: 없음
|
- 요청일: 없음
|
||||||
- 완료 근거: 아직 필수 기능과 완료 기준이 충족되지 않았다.
|
- 완료 근거: `us-instruments`, `us-calendar`는 `agent-task/archive/2026/06/m-us-market-expansion/01_market_foundation/complete.log`의 Roadmap Completion 근거로 완료되었다. 남은 필수 기능은 `us-daily-bars`, `shared-query`, `us-backtest`다.
|
||||||
- 리뷰 필요:
|
- 리뷰 필요:
|
||||||
- [ ] 사용자가 완료 결과를 확인했다
|
- [ ] 사용자가 완료 결과를 확인했다
|
||||||
- [ ] archive 이동을 승인했다
|
- [ ] archive 이동을 승인했다
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
<!-- task=m-us-market-expansion/02+01_kis_us_daily_bars plan=0 tag=KIS_US_DAILY -->
|
||||||
|
|
||||||
|
# Code Review Reference - KIS_US_DAILY
|
||||||
|
|
||||||
|
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||||
|
> Complete implementation-owned sections, then stop with active files in place and report ready for review. Do not finalize, archive, write `complete.log`, ask the user directly, or call `request_user_input`.
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
date=2026-06-06
|
||||||
|
task=m-us-market-expansion/02+01_kis_us_daily_bars, plan=0, tag=KIS_US_DAILY
|
||||||
|
|
||||||
|
## Roadmap Targets
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/us-market-expansion.md`
|
||||||
|
- Task ids:
|
||||||
|
- `us-daily-bars`: KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장
|
||||||
|
- Completion mode: check-on-pass
|
||||||
|
|
||||||
|
## 이 파일을 읽는 리뷰 에이전트에게
|
||||||
|
|
||||||
|
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
|
||||||
|
|
||||||
|
각 항목의 구현을 실제 소스 파일과 대조하고 검증 출력이 코드와 일치하는지 확인한다. PASS이면 완료 이벤트 메타데이터만 보고하고 roadmap 수정은 런타임 책임이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 항목별 완료 여부
|
||||||
|
|
||||||
|
| 항목 | 완료 여부 |
|
||||||
|
|------|---------|
|
||||||
|
| [KIS_US_DAILY-1] Provider Capability Gate | [x] |
|
||||||
|
| [KIS_US_DAILY-2] KIS Overseas Daily Normalization | [x] |
|
||||||
|
| [KIS_US_DAILY-3] Worker Import Path For US Watchlists | [x] |
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
|
||||||
|
- [x] [KIS_US_DAILY-1] provider capability를 도입해 KIS가 KR/US daily bars 지원 범위를 선언하고 unsupported market/venue 조합을 import 전에 거절한다. 검증: 한국장 pipeline을 깨지 않고 provider capability가 미국장 daily bar import를 허용한다.
|
||||||
|
- [x] [KIS_US_DAILY-2] KIS US daily bar fixture decoder/normalizer를 추가해 USD/NASDAQ/NYSE bar를 normalized model로 만든다.
|
||||||
|
- [x] [KIS_US_DAILY-3] worker socket/job/importer 경로가 US watchlist import request를 KIS provider로 전달하고 instrument/bar upsert count를 보존한다.
|
||||||
|
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
|
## 코드리뷰 전용 체크리스트
|
||||||
|
|
||||||
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
|
|
||||||
|
- [x] 판정을 append한다.
|
||||||
|
- [x] active review/plan 파일을 `*.log`로 아카이브한다.
|
||||||
|
- [x] `.gitignore` Agent-Ops block을 확인한다.
|
||||||
|
- [ ] PASS이면 `complete.log`를 작성하고 task directory를 archive로 이동한다.
|
||||||
|
- [ ] PASS이면 완료 이벤트 메타데이터를 보고한다.
|
||||||
|
- [x] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다.
|
||||||
|
|
||||||
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
|
- Capability 어휘를 새 `capability` 패키지 대신 `packages/domain/market/types.go`에 `market.ProviderCapability`로 추가했다. 근거: 공통 규칙 "새 파일 생성보다 기존 파일 수정 우선" + domain이 이미 `GetVenueMetadata`로 venue→market/timezone vocabulary를 소유하므로, transport/persistence-free 순수 매처는 domain altitude에 맞는다.
|
||||||
|
- 계획 pseudo-code의 `capability.RequireDailyBars(provider, selector)`를 `market.ProviderCapability`의 메서드로 구현했다(`cap.RequireDailyBars(provider, selector)`). `RegisterDailyBarImportHandler`의 `expectedProvider market.Provider` 파라미터를 `capability market.ProviderCapability`로 교체했고, `main.go` 와이어링을 `kis.Capability()`로 갱신했다.
|
||||||
|
- Capability gate는 venue를 필수로 본다. KIS US daily import는 venue로 overseas exchange(NAS/NYS)를 해석해야 하므로 venue 없는 daily import는 거절한다. 이에 맞춰 기존 jobs 테스트의 venue 없는 payload(override/mismatch 케이스)에 `venue`/`market`을 추가했다(테스트 의도는 유지).
|
||||||
|
- overseas endpoint는 계획 후보(`.../dailyprice`, `HHDFS76240000`)를 로컬 KIS 캐시 `examples_llm/overseas_stock/dailyprice`로 대조해 확정했다. `inquire-daily-chartprice`(FHKST03030100)는 지수 구성종목만 조회 가능하다는 공식 주석 때문에 개별 US 종목(AAPL/SPY)에는 `dailyprice`를 선택했다. 코드는 fixture assertion으로 endpoint/TR_ID/field(xymd, clos)를 잠근다.
|
||||||
|
- LiveProvider의 실제 overseas REST 호출 와이어링은 구현하지 않았다(plan 범위 밖: live credential smoke, 후속 split). KIS_US_DAILY-2는 fixture decoder/normalizer까지, KIS_US_DAILY-3은 fake provider/store 기반 테스트까지만 다룬다.
|
||||||
|
|
||||||
|
## 주요 설계 결정
|
||||||
|
|
||||||
|
- `market.ProviderCapability{Provider, Venues, Timeframes}` + `SupportsDailyBars(venue)` + `RequireDailyBars(provider, selector)`로 provider≠market 경계를 표현했다. KIS는 KRX/NASDAQ/NYSE를 한 capability에 선언하고, KR-only 후속 provider(예: Kiwoom)는 좁은 capability로 US를 자동 거절한다(코드 추가 없이 test fixture로 검증).
|
||||||
|
- overseas timestamp는 instrument venue metadata의 timezone을 사용한다(`venueLocation` helper). KRX는 tzdata 비의존 고정 +09:00, US venue는 `time.LoadLocation("America/New_York")`. 재현성을 위해 kis 패키지에 `_ "time/tzdata"` blank import를 추가해 host zoneinfo 유무와 무관하게 EDT/EST가 결정적으로 해석되게 했다. price currency는 instrument currency(USD)를 사용한다.
|
||||||
|
- domestic `NormalizeDailyBars`는 그대로 두고 `NormalizeOverseasDailyBars`를 분리 추가했다(KR fixture/테스트 무손상). 응답 envelope는 output1/output2로 동일하지만 OHLCV 필드명(xymd/clos/tvol)이 달라 별도 struct/decoder가 필요했다.
|
||||||
|
- importer/store는 in-memory store가 instrument struct 전체를 보존하므로 `AssetType`(equity/etf)이 instrument-first→bars-after upsert 경로에서 유지됨을 테스트로 잠갔다.
|
||||||
|
|
||||||
|
## 사용자 리뷰 요청
|
||||||
|
|
||||||
|
- 상태: 없음
|
||||||
|
- 사유 유형: 없음
|
||||||
|
- 결정 필요: 없음
|
||||||
|
- 차단 근거: 없음
|
||||||
|
- 실행한 검증/명령: 없음
|
||||||
|
- 자동 후속 불가 이유: 없음
|
||||||
|
- 재개 조건: 없음
|
||||||
|
|
||||||
|
## 리뷰어를 위한 체크포인트
|
||||||
|
|
||||||
|
- provider capability가 `provider != market` 원칙을 지키는지 확인한다.
|
||||||
|
- KIS US fixture가 endpoint/TR_ID/field mapping을 test로 잠그는지 확인한다.
|
||||||
|
- KR existing daily import tests가 계속 통과하는지 확인한다.
|
||||||
|
- live secret 없이 deterministic fixture/unit 검증만으로 PASS를 주장하는지 확인한다.
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
### KIS_US_DAILY-1 중간 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 ./packages/domain/... ./services/worker/internal/jobs
|
||||||
|
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/packages/domain/market 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s
|
||||||
|
```
|
||||||
|
|
||||||
|
### KIS_US_DAILY-2 중간 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 ./services/worker/internal/providers/kis
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.008s
|
||||||
|
```
|
||||||
|
|
||||||
|
### KIS_US_DAILY-3 중간 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 ./services/worker/internal/socket ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.060s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 최종 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 ./packages/domain/...
|
||||||
|
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/packages/domain/market 0.002s
|
||||||
|
|
||||||
|
$ go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.009s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.059s
|
||||||
|
|
||||||
|
$ go test -count=1 ./services/worker/...
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.014s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.014s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.015s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.012s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.013s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.013s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.004s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.023s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.012s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.061s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.066s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
|
||||||
|
|
||||||
|
$ bin/test
|
||||||
|
... (contracts-check + go test for contracts/domain/api/worker/cli modules + flutter test)
|
||||||
|
00:02 +60: All tests passed!
|
||||||
|
|
||||||
|
$ git diff --check
|
||||||
|
OK: no whitespace errors (no output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 참고: gofmt
|
||||||
|
```text
|
||||||
|
$ gofmt -l <변경 패키지들>
|
||||||
|
services/worker/internal/socket/paper_test.go
|
||||||
|
```
|
||||||
|
`paper_test.go`는 이번 변경 범위 밖의 기존 미포맷 파일이며 diff에 포함되지 않는다. 공통 규칙(기존 변경 되돌리지 않음, 요청 범위 밖 변경 금지)에 따라 손대지 않았다. 변경한 Go 파일은 모두 gofmt clean이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
|
||||||
|
|
||||||
|
## 코드리뷰 결과
|
||||||
|
|
||||||
|
- 종합 판정: FAIL
|
||||||
|
- 차원별 평가:
|
||||||
|
- correctness: Fail
|
||||||
|
- completeness: Fail
|
||||||
|
- test coverage: Fail
|
||||||
|
- API contract: Fail
|
||||||
|
- code quality: Pass
|
||||||
|
- plan deviation: Fail
|
||||||
|
- verification trust: Pass
|
||||||
|
- 발견된 문제:
|
||||||
|
- Required: [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:306) still routes every `LiveProvider.FetchDailyBars` request through the domestic `FetchDailyItemChartPrice` endpoint and [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:315) normalizes with the KR domestic normalizer. Because [services/worker/internal/providers/kis/daily_itemchartprice.go](/config/workspace/alt/services/worker/internal/providers/kis/daily_itemchartprice.go:32) advertises NASDAQ/NYSE support and [services/worker/cmd/alt-worker/main.go](/config/workspace/alt/services/worker/cmd/alt-worker/main.go:82) wires that capability into the real worker, a US import job is accepted but then fetched as a domestic KIS request and stored as a `KRX:*`/KRW instrument via [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:324). Fix by either routing NASDAQ/NYSE requests through the overseas `dailyprice` endpoint plus `NormalizeOverseasDailyBars`, with a regression test proving `AAPL` hits `OverseasDailyPricePath` and yields `NASDAQ:AAPL`/USD bars, or by not advertising US venues in the live worker capability until that provider path exists.
|
||||||
|
- 리뷰어 검증:
|
||||||
|
- `go test -count=1 ./packages/domain/...` PASS
|
||||||
|
- `go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket` PASS
|
||||||
|
- `go test -count=1 ./services/worker/...` PASS
|
||||||
|
- `bin/test` PASS
|
||||||
|
- `git diff --check` PASS
|
||||||
|
- `git diff --name-only -- '*.go' | xargs -r gofmt -l` PASS, no output
|
||||||
|
- 다음 단계: FAIL 후속 계획을 작성해 live KIS US daily provider path와 regression coverage를 완성한다.
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
<!-- task=m-us-market-expansion/02+01_kis_us_daily_bars plan=1 tag=REVIEW_KIS_US_DAILY -->
|
||||||
|
|
||||||
|
# Code Review Reference - REVIEW_KIS_US_DAILY
|
||||||
|
|
||||||
|
> **[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, user-owned 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`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||||
|
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||||
|
> 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-06-06
|
||||||
|
task=m-us-market-expansion/02+01_kis_us_daily_bars, plan=1, tag=REVIEW_KIS_US_DAILY
|
||||||
|
|
||||||
|
## Roadmap Targets
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/us-market-expansion.md`
|
||||||
|
- Task ids:
|
||||||
|
- `us-daily-bars`: KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장
|
||||||
|
- Completion mode: check-on-pass
|
||||||
|
|
||||||
|
## 이 파일을 읽는 리뷰 에이전트에게
|
||||||
|
|
||||||
|
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||||
|
|
||||||
|
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||||
|
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||||
|
|
||||||
|
1. 판정을 append한다.
|
||||||
|
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
|
||||||
|
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-us-market-expansion/02+01_kis_us_daily_bars/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||||
|
4. PASS이고 task group이 `m-us-market-expansion`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||||
|
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 항목별 완료 여부
|
||||||
|
|
||||||
|
| 항목 | 완료 여부 |
|
||||||
|
|------|---------|
|
||||||
|
| [REVIEW_KIS_US_DAILY-1] Live KIS Overseas Daily Provider Path | [x] |
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
|
||||||
|
- [x] [REVIEW_KIS_US_DAILY-1] `kis.LiveProvider`가 NASDAQ/NYSE daily import를 overseas `dailyprice` endpoint로 fetch/normalize하고, real worker가 허용한 US request를 `NASDAQ:AAPL`/USD daily bars로 저장할 수 있음을 regression test로 증명한다.
|
||||||
|
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
|
## 코드리뷰 전용 체크리스트
|
||||||
|
|
||||||
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
|
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||||
|
|
||||||
|
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||||
|
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||||
|
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||||
|
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||||
|
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||||
|
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||||
|
- [ ] PASS이면 active task 디렉터리 `agent-task/m-us-market-expansion/02+01_kis_us_daily_bars/`를 `agent-task/archive/YYYY/MM/m-us-market-expansion/02+01_kis_us_daily_bars/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||||
|
- [ ] PASS이고 task group이 `m-us-market-expansion`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||||
|
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-us-market-expansion/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||||
|
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||||
|
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||||
|
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||||
|
|
||||||
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
|
- 계획 pseudo-code는 `FetchDailyBars` 안에 venue switch를 inline으로 두었으나, 기존 domestic 루프를 `fetchDomesticDailyBars`, 신규 overseas 루프를 `fetchOverseasDailyBars`로 분리했다. 근거: KR/US 경로의 fetch+normalize 책임을 분리해 가독성과 테스트 격리를 높이고, 공통 validation(provider/symbols/range)은 `FetchDailyBars`에 유지했다. 외부 동작은 동일하다.
|
||||||
|
- overseas dailyprice base date는 계획대로 `request.To.Format(kisDateLayout)`(BYMD)를 사용한다. 도메스틱 range(From/To)와 달리 overseas dailyprice는 단일 기준일에서 역방향으로 회수하므로 To만 전달한다. From/To 둘 다 필수라는 기존 importer 계약 검증은 그대로 유지했다.
|
||||||
|
- 검증 명령은 plan의 계약을 그대로 실행했다(대체 없음).
|
||||||
|
|
||||||
|
## 주요 설계 결정
|
||||||
|
|
||||||
|
- venue 분기를 provider 자체에 두어 `LiveProvider.FetchDailyBars`가 KRX/빈 venue → domestic, NASDAQ/NYSE → overseas, 그 외 → `ErrorMalformed` 거절을 수행한다. capability gate(job/socket)가 비활성이거나 우회되는 직접 provider 호출에서도 US request가 domestic endpoint로 떨어지지 않도록 마지막 방어선을 provider에 고정했다.
|
||||||
|
- overseas client는 도메스틱과 같은 `doJSON`/auth/error-classify 인프라를 재사용하고, 별도 `OverseasDailyPriceQuery`(EXCD/SYMB/GUBN/BYMD/MODP)와 `InquireOverseasDailyPrice`/`FetchOverseasDailyPrice`를 추가했다. endpoint/TR_ID(`OverseasDailyPricePath`/`HHDFS76240000`)와 EXCD 매핑(NASDAQ=NAS, NYSE=NYS)은 로컬 KIS 캐시 `examples_llm/overseas_stock/dailyprice`로 확정했고 httptest로 잠갔다.
|
||||||
|
- US instrument는 venue metadata에서 market/currency를 파생한다(`overseasInstrument`). overseas dailyprice 응답에는 종목명/asset type이 없으므로 instrument에 `Name`/`AssetType`을 채우지 않고 id/market/venue/currency/provider symbol과 bars correctness만 고정했다. asset type/name enrichment는 plan 범위 결정 근거에 따라 후속 split 과제로 둔다(이 경계를 리뷰 파일에 명시).
|
||||||
|
- timestamp는 1차 구현에서 추가한 `NormalizeOverseasDailyBars` + `venueLocation`(America/New_York, tzdata 임베드)을 그대로 사용하므로 US bar는 New York 자정(EDT -04:00) 기준으로 저장된다.
|
||||||
|
|
||||||
|
## 사용자 리뷰 요청
|
||||||
|
|
||||||
|
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||||
|
|
||||||
|
- 상태: 없음
|
||||||
|
- 사유 유형: 없음
|
||||||
|
- 결정 필요: 없음
|
||||||
|
- 차단 근거: 없음
|
||||||
|
- 실행한 검증/명령: 없음
|
||||||
|
- 자동 후속 불가 이유: 없음
|
||||||
|
- 재개 조건: 없음
|
||||||
|
|
||||||
|
## 리뷰어를 위한 체크포인트
|
||||||
|
|
||||||
|
- `kis.Capability()`가 허용한 NASDAQ/NYSE request가 live provider에서 domestic endpoint로 떨어지지 않는지 확인한다.
|
||||||
|
- overseas client test가 `OverseasDailyPricePath`, `OverseasDailyPriceTRID`, `EXCD`, `SYMB`, `BYMD`를 실제 HTTP request에서 검증하는지 확인한다.
|
||||||
|
- importer-backed regression이 `NASDAQ:AAPL`, `MarketUS`, `VenueNASDAQ`, USD bar currency를 저장 결과로 검증하는지 확인한다.
|
||||||
|
- KRX domestic live provider test가 그대로 통과하는지 확인한다.
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||||
|
|
||||||
|
필수 규칙:
|
||||||
|
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||||
|
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||||
|
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||||
|
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||||
|
|
||||||
|
### REVIEW_KIS_US_DAILY-1 중간 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 -run 'Test(InquireOverseasDailyPriceSendsHeadersAndDecodes|LiveProviderFetchesAndNormalizesOverseasDailyBars|LiveProviderImportStoresUSDailyBars|LiveProviderFetchesAndNormalizesDailyBars)' ./services/worker/internal/providers/kis
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.011s
|
||||||
|
|
||||||
|
# -v 실행 시 개별 통과 확인:
|
||||||
|
# --- PASS: TestLiveProviderFetchesAndNormalizesDailyBars (0.00s)
|
||||||
|
# --- PASS: TestInquireOverseasDailyPriceSendsHeadersAndDecodes (0.00s)
|
||||||
|
# --- PASS: TestLiveProviderFetchesAndNormalizesOverseasDailyBars (0.00s)
|
||||||
|
# --- PASS: TestLiveProviderImportStoresUSDailyBars (0.00s)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 최종 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.014s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.004s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.063s
|
||||||
|
|
||||||
|
$ go test -count=1 ./services/worker/...
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.012s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.005s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.005s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.007s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.009s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.018s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.063s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.081s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
|
||||||
|
|
||||||
|
$ bin/test
|
||||||
|
... (contracts-check + go test for contracts/domain/api/worker/cli modules + flutter test)
|
||||||
|
00:02 +60: All tests passed!
|
||||||
|
|
||||||
|
$ git diff --check
|
||||||
|
(출력 없음 — 통과)
|
||||||
|
|
||||||
|
$ git diff --name-only -- '*.go' | xargs -r gofmt -l
|
||||||
|
(출력 없음 — 통과)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||||
|
> If anything is blank, go back and fill it in before saving this file.
|
||||||
|
> Leave review-agent-only sections unchanged.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| 섹션 | 소유자 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 헤더 주석, 개요, Roadmap Targets, 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
|
||||||
|
| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크만 수행 |
|
||||||
|
| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
|
||||||
|
| 코드리뷰 전용 체크리스트 | Review agent only | 구현 에이전트가 수정하지 않음 |
|
||||||
|
| 계획 대비 변경 사항, 주요 설계 결정, 사용자 리뷰 요청, 검증 결과 | 구현 에이전트 | 실제 내용과 stdout/stderr를 채움 |
|
||||||
|
| 코드리뷰 결과 | Review agent only | 리뷰 에이전트가 append |
|
||||||
|
|
||||||
|
## 코드리뷰 결과
|
||||||
|
|
||||||
|
- 종합 판정: FAIL
|
||||||
|
- 차원별 평가:
|
||||||
|
- correctness: Fail
|
||||||
|
- completeness: Fail
|
||||||
|
- test coverage: Fail
|
||||||
|
- API contract: Fail
|
||||||
|
- code quality: Pass
|
||||||
|
- plan deviation: Pass
|
||||||
|
- verification trust: Pass
|
||||||
|
- 발견된 문제:
|
||||||
|
- Required: [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:394) computes both `from` and `to`, but the US branch only passes `to` into [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:404), and [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:439) fetches/normalizes every row returned by KIS overseas dailyprice. That drops the inclusive date-range contract described in [services/worker/internal/marketdata/importer/importer.go](/config/workspace/alt/services/worker/internal/marketdata/importer/importer.go:17) and enforced by the socket surface in [services/worker/internal/socket/market.go](/config/workspace/alt/services/worker/internal/socket/market.go:120): an overseas response containing rows before `From` would still be upserted. Fix by passing the requested `from`/`to` dates into the overseas path and filtering normalized bars to the requested inclusive local date range before returning them, with a regression test that includes an out-of-range overseas row and proves only in-range US bars are stored.
|
||||||
|
- 리뷰어 검증:
|
||||||
|
- `go test -count=1 -run 'Test(InquireOverseasDailyPriceSendsHeadersAndDecodes|LiveProviderFetchesAndNormalizesOverseasDailyBars|LiveProviderImportStoresUSDailyBars|LiveProviderFetchesAndNormalizesDailyBars)' ./services/worker/internal/providers/kis` PASS
|
||||||
|
- `go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket` PASS
|
||||||
|
- `go test -count=1 ./services/worker/...` PASS
|
||||||
|
- `bin/test` PASS
|
||||||
|
- `git diff --check` PASS
|
||||||
|
- `git diff --name-only -- '*.go' | xargs -r gofmt -l` PASS, no output
|
||||||
|
- 다음 단계: FAIL 후속 계획을 작성해 KIS overseas dailyprice 결과를 requested inclusive date range로 제한하고 regression coverage를 추가한다.
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
<!-- task=m-us-market-expansion/02+01_kis_us_daily_bars plan=2 tag=REVIEW_REVIEW_KIS_US_DAILY -->
|
||||||
|
|
||||||
|
# Code Review Reference - REVIEW_REVIEW_KIS_US_DAILY
|
||||||
|
|
||||||
|
> **[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, user-owned 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`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||||
|
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||||
|
> 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-06-06
|
||||||
|
task=m-us-market-expansion/02+01_kis_us_daily_bars, plan=2, tag=REVIEW_REVIEW_KIS_US_DAILY
|
||||||
|
|
||||||
|
## Roadmap Targets
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/us-market-expansion.md`
|
||||||
|
- Task ids:
|
||||||
|
- `us-daily-bars`: KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장
|
||||||
|
- Completion mode: check-on-pass
|
||||||
|
|
||||||
|
## 이 파일을 읽는 리뷰 에이전트에게
|
||||||
|
|
||||||
|
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||||
|
|
||||||
|
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||||
|
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||||
|
|
||||||
|
1. 판정을 append한다.
|
||||||
|
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
|
||||||
|
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-us-market-expansion/02+01_kis_us_daily_bars/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||||
|
4. PASS이고 task group이 `m-us-market-expansion`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||||
|
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 항목별 완료 여부
|
||||||
|
|
||||||
|
| 항목 | 완료 여부 |
|
||||||
|
|------|---------|
|
||||||
|
| [REVIEW_REVIEW_KIS_US_DAILY-1] Overseas Daily Date Range Filter | [x] |
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
|
||||||
|
- [x] [REVIEW_REVIEW_KIS_US_DAILY-1] KIS overseas dailyprice 결과를 requested inclusive `From`/`To` local date range로 필터링하고, `From` 이전 row가 저장되지 않는 regression test를 추가한다.
|
||||||
|
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
|
## 코드리뷰 전용 체크리스트
|
||||||
|
|
||||||
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
|
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||||
|
|
||||||
|
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||||
|
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||||
|
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||||
|
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
|
||||||
|
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||||
|
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||||
|
- [x] PASS이면 active task 디렉터리 `agent-task/m-us-market-expansion/02+01_kis_us_daily_bars/`를 `agent-task/archive/YYYY/MM/m-us-market-expansion/02+01_kis_us_daily_bars/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||||
|
- [x] PASS이고 task group이 `m-us-market-expansion`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||||
|
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-us-market-expansion/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||||
|
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일을 작성하고 `complete.log`를 작성하지 않는다.
|
||||||
|
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||||
|
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||||
|
|
||||||
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
|
- 계획 대비 변경 사항이 없습니다.
|
||||||
|
|
||||||
|
## 주요 설계 결정
|
||||||
|
|
||||||
|
1. `fetchOverseasDailyBars` 시그니처를 수정하여 `fromDate`와 `toDate`를 추가로 전달받도록 구성했습니다.
|
||||||
|
2. API 응답 데이터가 `BYMD`로부터 역방향으로 반환되는데, `time.Time` 비교 대신 `bar.Timestamp.Format("20060102")` 포맷팅 문자열을 사용하여 timezone 오프셋 영향 없이 날짜 문자열 기준으로 range 필터링(inclusive)을 수행했습니다.
|
||||||
|
|
||||||
|
## 사용자 리뷰 요청
|
||||||
|
|
||||||
|
- 상태: 없음
|
||||||
|
- 사유 유형: 없음
|
||||||
|
- 결정 필요: 없음
|
||||||
|
- 차단 근거: 없음
|
||||||
|
- 실행한 검증/명령: 없음
|
||||||
|
- 자동 후속 불가 이유: 없음
|
||||||
|
- 재개 조건: 없음
|
||||||
|
|
||||||
|
## 리뷰어를 위한 체크포인트
|
||||||
|
|
||||||
|
- overseas dailyprice path가 `From` 이전 row를 반환해도 provider/importer 결과가 requested inclusive date range로 제한되는지 확인한다.
|
||||||
|
- 필터가 `time.Time` instant offset 비교로 `To` 날짜 New York midnight row를 잘못 버리지 않고, `bar.Timestamp.Format(kisDateLayout)` 기준으로 비교하는지 확인한다.
|
||||||
|
- 기존 US overseas endpoint regression과 KRX domestic regression이 계속 통과하는지 확인한다.
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
### REVIEW_REVIEW_KIS_US_DAILY-1 중간 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 -run 'TestLiveProvider(FiltersOverseasDailyBarsToRequestedRange|FetchesAndNormalizesOverseasDailyBars|ImportStoresUSDailyBars)' ./services/worker/internal/providers/kis
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.006s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 최종 검증
|
||||||
|
```text
|
||||||
|
$ go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.011s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.062s
|
||||||
|
|
||||||
|
$ go test -count=1 ./services/worker/...
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.004s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.002s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.007s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck0.006s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.005s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.020s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.003s
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.062s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
|
||||||
|
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.078s
|
||||||
|
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
|
||||||
|
|
||||||
|
$ bin/test
|
||||||
|
All tests passed!
|
||||||
|
|
||||||
|
$ git diff --check
|
||||||
|
|
||||||
|
|
||||||
|
$ git diff --name-only -- '*.go' | xargs -r gofmt -l
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||||
|
> If anything is blank, go back and fill it in before saving this file.
|
||||||
|
> Leave review-agent-only sections unchanged.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
| 섹션 | 소유자 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 헤더 주석, 개요, Roadmap Targets, 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
|
||||||
|
| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크만 수행 |
|
||||||
|
| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
|
||||||
|
| 코드리뷰 전용 체크리스트 | Review agent only | 구현 에이전트가 수정하지 않음 |
|
||||||
|
| 계획 대비 변경 사항, 주요 설계 결정, 사용자 리뷰 요청, 검증 결과 | 구현 에이전트 | 실제 내용과 stdout/stderr를 채움 |
|
||||||
|
| 코드리뷰 결과 | Review agent only | 리뷰 에이전트가 append |
|
||||||
|
|
||||||
|
## 코드리뷰 결과
|
||||||
|
|
||||||
|
- 종합 판정: PASS
|
||||||
|
- 차원별 평가:
|
||||||
|
- correctness: Pass
|
||||||
|
- completeness: Pass
|
||||||
|
- test coverage: Pass
|
||||||
|
- API contract: Pass
|
||||||
|
- code quality: Pass
|
||||||
|
- plan deviation: Pass
|
||||||
|
- verification trust: Pass
|
||||||
|
- 발견된 문제: 없음
|
||||||
|
- 리뷰어 검증:
|
||||||
|
- `go test -count=1 -run 'TestLiveProvider(FiltersOverseasDailyBarsToRequestedRange|FetchesAndNormalizesOverseasDailyBars|ImportStoresUSDailyBars)' ./services/worker/internal/providers/kis` PASS
|
||||||
|
- `go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket` PASS
|
||||||
|
- `go test -count=1 ./services/worker/...` PASS
|
||||||
|
- `bin/test` PASS
|
||||||
|
- `git diff --check` PASS
|
||||||
|
- `git diff --name-only -- '*.go' | xargs -r gofmt -l` PASS, no output
|
||||||
|
- 다음 단계: PASS로 `complete.log`를 작성하고 task directory를 archive로 이동한다.
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Complete - m-us-market-expansion/02+01_kis_us_daily_bars
|
||||||
|
|
||||||
|
## 완료 일시
|
||||||
|
|
||||||
|
2026-06-06
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
KIS US daily bars import path를 3회 리뷰 루프로 완료했으며 최종 판정은 PASS다.
|
||||||
|
|
||||||
|
## 루프 이력
|
||||||
|
|
||||||
|
| Plan | Review | Verdict | 메모 |
|
||||||
|
|------|--------|---------|------|
|
||||||
|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | KIS capability가 US venues를 열었지만 live provider가 domestic endpoint/normalizer만 사용해 follow-up 필요 |
|
||||||
|
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | overseas dailyprice 응답을 requested date range로 제한하지 않아 follow-up 필요 |
|
||||||
|
| `plan_local_G06_2.log` | `code_review_local_G06_2.log` | PASS | KIS overseas dailyprice live provider path와 inclusive date range filter regression이 통과 |
|
||||||
|
|
||||||
|
## 구현/정리 내용
|
||||||
|
|
||||||
|
- `market.ProviderCapability`와 KIS capability declaration을 추가해 KRX/NASDAQ/NYSE daily bars 지원 여부를 import 전에 검증한다.
|
||||||
|
- KIS overseas `dailyprice` fixture decoder/normalizer를 추가해 US/NASDAQ daily bars를 USD와 America/New_York daily timestamp로 변환한다.
|
||||||
|
- worker job/socket/importer 경로가 US watchlist import request를 provider로 전달하고 instrument/bar upsert count를 보존하도록 테스트를 보강했다.
|
||||||
|
- `kis.LiveProvider`가 NASDAQ/NYSE 요청을 overseas `dailyprice` endpoint로 라우팅하고, `From`/`To` inclusive local date range 밖의 overseas rows를 저장하지 않도록 필터링한다.
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
- `go test -count=1 -run 'TestLiveProvider(FiltersOverseasDailyBarsToRequestedRange|FetchesAndNormalizesOverseasDailyBars|ImportStoresUSDailyBars)' ./services/worker/internal/providers/kis` - PASS; `ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.006s`
|
||||||
|
- `go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket` - PASS; focused worker provider/importer/jobs/socket packages passed.
|
||||||
|
- `go test -count=1 ./services/worker/...` - PASS; all worker packages passed, no-test packages reported as `[no test files]`.
|
||||||
|
- `bin/test` - PASS; workspace Go and Flutter tests completed with `All tests passed!`.
|
||||||
|
- `git diff --check` - PASS; no whitespace errors.
|
||||||
|
- `git diff --name-only -- '*.go' | xargs -r gofmt -l` - PASS; no output.
|
||||||
|
|
||||||
|
## Roadmap Completion
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/us-market-expansion.md`
|
||||||
|
- Completed task ids:
|
||||||
|
- `us-daily-bars`: PASS; evidence=`agent-task/archive/2026/06/m-us-market-expansion/02+01_kis_us_daily_bars/plan_local_G06_2.log`, `agent-task/archive/2026/06/m-us-market-expansion/02+01_kis_us_daily_bars/code_review_local_G06_2.log`; verification=`go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket`, `go test -count=1 ./services/worker/...`, `bin/test`
|
||||||
|
- Not completed task ids: 없음
|
||||||
|
|
||||||
|
## 잔여 Nit
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
<!-- task=m-us-market-expansion/02+01_kis_us_daily_bars plan=1 tag=REVIEW_KIS_US_DAILY -->
|
||||||
|
|
||||||
|
# Implementation Plan - REVIEW_KIS_US_DAILY
|
||||||
|
|
||||||
|
## 이 파일을 읽는 구현 에이전트에게
|
||||||
|
|
||||||
|
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 결정, 검증 출력으로 채우는 것이다. 검증 명령은 이 plan의 계약이므로 그대로 실행하고 stdout/stderr를 review stub에 붙인다. finalization, archive, `complete.log` 작성은 code-review 전용이다. 사용자-only 결정, 사용자 소유 외부 환경/secret, 또는 범위 충돌로 막히면 active review stub의 `사용자 리뷰 요청`에 근거를 쓰고 멈춘다. 직접 사용자에게 질문하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
1차 구현은 `kis.Capability()`가 NASDAQ/NYSE daily bars를 지원한다고 선언했고, worker main은 이 capability를 실제 KIS importer에 연결했다. 그러나 `LiveProvider.FetchDailyBars`는 venue와 무관하게 domestic KIS endpoint와 KR normalizer만 호출한다. 따라서 real worker US import는 gate를 통과한 뒤 `AAPL`을 domestic request로 fetch하고 `KRX:AAPL`/KRW로 저장할 수 있다.
|
||||||
|
|
||||||
|
## 사용자 리뷰 요청 흐름
|
||||||
|
|
||||||
|
구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자에게 질문하지 않는다. code-review가 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||||
|
|
||||||
|
## Roadmap Targets
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/us-market-expansion.md`
|
||||||
|
- Task ids:
|
||||||
|
- `us-daily-bars`: KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장
|
||||||
|
- Completion mode: check-on-pass
|
||||||
|
|
||||||
|
## 분석 결과
|
||||||
|
|
||||||
|
### 읽은 파일
|
||||||
|
|
||||||
|
- `agent-ops/rules/project/rules.md`
|
||||||
|
- `agent-ops/rules/private/rules.md`
|
||||||
|
- `agent-ops/rules/common/rules-roadmap.md`
|
||||||
|
- `agent-ops/skills/common/router.md`
|
||||||
|
- `agent-ops/skills/common/code-review/SKILL.md`
|
||||||
|
- `agent-ops/skills/common/plan/SKILL.md`
|
||||||
|
- `agent-test/local/rules.md`
|
||||||
|
- `agent-test/local/domain-model-smoke.md`
|
||||||
|
- `agent-test/local/worker-smoke.md`
|
||||||
|
- `agent-task/m-us-market-expansion/02+01_kis_us_daily_bars/plan_cloud_G07_0.log`
|
||||||
|
- `agent-task/m-us-market-expansion/02+01_kis_us_daily_bars/code_review_cloud_G07_0.log`
|
||||||
|
- `agent-task/archive/2026/06/m-us-market-expansion/01_market_foundation/complete.log`
|
||||||
|
- `packages/domain/market/types.go`
|
||||||
|
- `services/worker/cmd/alt-worker/main.go`
|
||||||
|
- `services/worker/internal/jobs/marketdata_jobs.go`
|
||||||
|
- `services/worker/internal/jobs/marketdata_jobs_test.go`
|
||||||
|
- `services/worker/internal/marketdata/importer/importer.go`
|
||||||
|
- `services/worker/internal/marketdata/importer/importer_test.go`
|
||||||
|
- `services/worker/internal/providers/kis/daily_itemchartprice.go`
|
||||||
|
- `services/worker/internal/providers/kis/daily_itemchartprice_test.go`
|
||||||
|
- `services/worker/internal/providers/kis/live_client.go`
|
||||||
|
- `services/worker/internal/providers/kis/live_client_test.go`
|
||||||
|
- `services/worker/internal/socket/market_test.go`
|
||||||
|
- `services/worker/testdata/providers/kis/overseas_dailyprice_request.sample.json`
|
||||||
|
- `services/worker/testdata/providers/kis/overseas_dailyprice_response.sample.json`
|
||||||
|
- `services/worker/testdata/providers/kis/overseas_daily_bars_normalized.expected.json`
|
||||||
|
|
||||||
|
### 테스트 환경 규칙
|
||||||
|
|
||||||
|
`test_env=local`. `agent-test/local/rules.md`를 읽었고, 변경 범위는 worker + domain-model smoke에 걸린다. 이 follow-up은 worker live provider 경로 보정이므로 기본 검증은 `go test -count=1 ./services/worker/...`와 focused provider test다. 전체 milestone 신뢰를 위해 `bin/test`와 `git diff --check`도 유지한다. live KIS credential smoke는 여전히 범위 밖이며 tracked 파일에 secret을 쓰지 않는다.
|
||||||
|
|
||||||
|
### 테스트 커버리지 공백
|
||||||
|
|
||||||
|
- 현재 `TestLiveProviderFetchesAndNormalizesDailyBars`는 KRX/domestic path만 검증한다.
|
||||||
|
- `TestRegisterDailyBarImportHandlerDispatchesUSWatchlist`는 capability가 US request를 importer로 넘기는지만 보고, 실제 `kis.LiveProvider`가 overseas endpoint를 쓰는지 검증하지 않는다.
|
||||||
|
- `NormalizeOverseasDailyBars` fixture test는 decoder/normalizer만 잠그며 live client request path, TR_ID, params, imported instrument id/currency를 end-to-end로 잠그지 않는다.
|
||||||
|
|
||||||
|
### 심볼 참조
|
||||||
|
|
||||||
|
Renamed/removed symbol: none. New or changed references to inspect after implementation: `FetchDailyBars`, `InquireDailyItemChartPrice`, new overseas query/client helper, `NormalizeOverseasDailyBars`, `kis.Capability()`.
|
||||||
|
|
||||||
|
### 분할 판단
|
||||||
|
|
||||||
|
이 task는 기존 split subtask `02+01_kis_us_daily_bars` 안의 review follow-up이다. 디렉터리명 기준 선행 `01_market_foundation`은 `agent-task/archive/2026/06/m-us-market-expansion/01_market_foundation/complete.log`로 충족되었다. Required 이슈가 하나이며 수정 파일과 검증이 `services/worker/internal/providers/kis` 중심으로 좁으므로 새 subtask로 쪼개지 않는다.
|
||||||
|
|
||||||
|
### 범위 결정 근거
|
||||||
|
|
||||||
|
이 follow-up은 US daily bars를 허용한 live KIS provider path의 misrouting만 고친다. KIS live credential smoke, provider symbol enrichment, ETF 정확 분류, shared query surface, backtest smoke는 후속 split 범위다. 해외 dailyprice 응답에 이름/asset type이 없으면 이 plan에서 외부 조회를 추가하지 말고, instrument id/market/venue/currency/provider symbol과 bars correctness를 우선 고정한다.
|
||||||
|
|
||||||
|
### 빌드 등급
|
||||||
|
|
||||||
|
`cloud-G07`: 외부 provider request shape, live worker wiring, fixture/httptest evidence가 함께 걸린 결함이며 1차 검증이 놓친 runtime 경로를 복구해야 한다.
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
|
||||||
|
- [ ] [REVIEW_KIS_US_DAILY-1] `kis.LiveProvider`가 NASDAQ/NYSE daily import를 overseas `dailyprice` endpoint로 fetch/normalize하고, real worker가 허용한 US request를 `NASDAQ:AAPL`/USD daily bars로 저장할 수 있음을 regression test로 증명한다.
|
||||||
|
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
|
## 의존 관계 및 구현 순서
|
||||||
|
|
||||||
|
1. overseas client request helper와 tests를 먼저 추가한다.
|
||||||
|
2. `LiveProvider.FetchDailyBars` venue branch를 연결한다.
|
||||||
|
3. importer-backed live provider regression으로 storage result까지 잠근다.
|
||||||
|
|
||||||
|
### [REVIEW_KIS_US_DAILY-1] Live KIS Overseas Daily Provider Path
|
||||||
|
|
||||||
|
문제: [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:306)는 모든 symbol에 대해 domestic `FetchDailyItemChartPrice`를 호출하고, [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:315)는 KR normalizer를 사용한다. 하지만 [services/worker/internal/providers/kis/daily_itemchartprice.go](/config/workspace/alt/services/worker/internal/providers/kis/daily_itemchartprice.go:32)는 NASDAQ/NYSE capability를 선언하고 [services/worker/cmd/alt-worker/main.go](/config/workspace/alt/services/worker/cmd/alt-worker/main.go:82)는 이 capability를 실제 worker importer에 연결한다.
|
||||||
|
|
||||||
|
해결 방법:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Before services/worker/internal/providers/kis/live_client.go:305
|
||||||
|
for _, symbol := range request.Selector.Symbols {
|
||||||
|
resp, err := p.client.FetchDailyItemChartPrice(ctx, DailyItemChartPriceQuery{
|
||||||
|
Symbol: symbol,
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
})
|
||||||
|
...
|
||||||
|
inst := instrumentFromDailyResponse(resp, symbol)
|
||||||
|
bars, err := NormalizeDailyBars(resp, inst)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// After
|
||||||
|
switch request.Selector.Venue {
|
||||||
|
case "", market.VenueKRX:
|
||||||
|
// existing domestic path, preserved for KR smoke.
|
||||||
|
case market.VenueNASDAQ, market.VenueNYSE:
|
||||||
|
// FetchOverseasDailyPrice with OverseasDailyPricePath/TR_ID,
|
||||||
|
// EXCD derived from venue (NASDAQ=NAS, NYSE=NYS), SYMB=symbol,
|
||||||
|
// BYMD=request.To.Format(kisDateLayout), then NormalizeOverseasDailyBars.
|
||||||
|
default:
|
||||||
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: ...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 파일 및 체크리스트:
|
||||||
|
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client.go`: add an overseas daily price query/client method using `OverseasDailyPricePath`, `OverseasDailyPriceTRID`, `AUTH`, `EXCD`, `SYMB`, `GUBN`, `BYMD`, `MODP` from the fixture contract.
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client.go`: branch `FetchDailyBars` by venue. KRX keeps the existing domestic path. NASDAQ/NYSE uses overseas dailyprice, `NormalizeOverseasDailyBars`, venue metadata currency, and `InstrumentID("<VENUE>:<SYMBOL>")`.
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client.go`: reject unsupported venues in the provider itself so direct provider tests cannot bypass capability validation and silently fall back to domestic behavior.
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client_test.go`: add `TestInquireOverseasDailyPriceSendsHeadersAndDecodes` asserting path, `tr_id`, `EXCD=NAS`, `SYMB=AAPL`, `BYMD`, and fixture response decode.
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client_test.go`: add `TestLiveProviderFetchesAndNormalizesOverseasDailyBars` asserting a NASDAQ request hits `OverseasDailyPricePath` rather than `DailyItemChartPricePath`, returns `NASDAQ:AAPL`, `MarketUS`, `VenueNASDAQ`, USD close currency, and New York timestamp.
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client_test.go`: add importer-backed regression, for example `TestLiveProviderImportStoresUSDailyBars`, proving `importer.New(provider, store).ImportDailyBars` stores one US instrument and two USD bars for a NASDAQ request.
|
||||||
|
- [ ] Keep existing KRX `TestLiveProviderFetchesAndNormalizesDailyBars` passing.
|
||||||
|
|
||||||
|
테스트 작성: 작성한다. 새 tests는 live credentials 없이 `httptest.Server`와 existing overseas fixture를 사용한다. If overseas dailyprice cannot represent exact asset type, do not add a fake external lookup; assert market/venue/currency/provider symbol and document the asset-type boundary in the review file.
|
||||||
|
|
||||||
|
중간 검증:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 -run 'Test(InquireOverseasDailyPriceSendsHeadersAndDecodes|LiveProviderFetchesAndNormalizesOverseasDailyBars|LiveProviderImportStoresUSDailyBars|LiveProviderFetchesAndNormalizesDailyBars)' ./services/worker/internal/providers/kis
|
||||||
|
```
|
||||||
|
|
||||||
|
## 수정 파일 요약
|
||||||
|
|
||||||
|
| 파일 | 항목 |
|
||||||
|
|---|---|
|
||||||
|
| `services/worker/internal/providers/kis/live_client.go` | REVIEW_KIS_US_DAILY-1 |
|
||||||
|
| `services/worker/internal/providers/kis/live_client_test.go` | REVIEW_KIS_US_DAILY-1 |
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 -run 'Test(InquireOverseasDailyPriceSendsHeadersAndDecodes|LiveProviderFetchesAndNormalizesOverseasDailyBars|LiveProviderImportStoresUSDailyBars|LiveProviderFetchesAndNormalizesDailyBars)' ./services/worker/internal/providers/kis
|
||||||
|
go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket
|
||||||
|
go test -count=1 ./services/worker/...
|
||||||
|
bin/test
|
||||||
|
git diff --check
|
||||||
|
git diff --name-only -- '*.go' | xargs -r gofmt -l
|
||||||
|
```
|
||||||
|
|
||||||
|
`go test -count=1`은 fresh 실행을 요구한다. 마지막 `gofmt -l` 명령은 출력이 없어야 통과다.
|
||||||
|
|
||||||
|
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
<!-- task=m-us-market-expansion/02+01_kis_us_daily_bars plan=2 tag=REVIEW_REVIEW_KIS_US_DAILY -->
|
||||||
|
|
||||||
|
# Implementation Plan - REVIEW_REVIEW_KIS_US_DAILY
|
||||||
|
|
||||||
|
## 이 파일을 읽는 구현 에이전트에게
|
||||||
|
|
||||||
|
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 결정, 검증 출력으로 채우는 것이다. 검증 명령은 이 plan의 계약이므로 그대로 실행하고 stdout/stderr를 review stub에 붙인다. finalization, archive, `complete.log` 작성은 code-review 전용이다. 사용자-only 결정, 사용자 소유 외부 환경/secret, 또는 범위 충돌로 막히면 active review stub의 `사용자 리뷰 요청`에 근거를 쓰고 멈춘다. 직접 사용자에게 질문하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
이전 follow-up은 US/NASDAQ request가 domestic KIS endpoint로 떨어지는 결함을 고쳤다. 하지만 overseas `dailyprice`는 `BYMD` 기준 역방향 응답이고, 현재 구현은 `To`만 전달한 뒤 모든 output row를 저장한다. `DailyBarRequest`와 socket import surface의 inclusive date-range 계약을 지키려면 US path도 `From`/`To` 범위 밖 bar를 반환하지 않아야 한다.
|
||||||
|
|
||||||
|
## 사용자 리뷰 요청 흐름
|
||||||
|
|
||||||
|
구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자에게 질문하지 않는다. code-review가 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||||
|
|
||||||
|
## Roadmap Targets
|
||||||
|
|
||||||
|
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/us-market-expansion.md`
|
||||||
|
- Task ids:
|
||||||
|
- `us-daily-bars`: KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장
|
||||||
|
- Completion mode: check-on-pass
|
||||||
|
|
||||||
|
## 분석 결과
|
||||||
|
|
||||||
|
### 읽은 파일
|
||||||
|
|
||||||
|
- `agent-test/local/rules.md`
|
||||||
|
- `agent-test/local/worker-smoke.md`
|
||||||
|
- `agent-test/local/domain-model-smoke.md`
|
||||||
|
- `agent-task/m-us-market-expansion/02+01_kis_us_daily_bars/plan_cloud_G07_1.log`
|
||||||
|
- `agent-task/m-us-market-expansion/02+01_kis_us_daily_bars/code_review_cloud_G07_1.log`
|
||||||
|
- `services/worker/internal/marketdata/importer/importer.go`
|
||||||
|
- `services/worker/internal/socket/market.go`
|
||||||
|
- `services/worker/internal/providers/kis/live_client.go`
|
||||||
|
- `services/worker/internal/providers/kis/live_client_test.go`
|
||||||
|
- `services/worker/internal/providers/kis/daily_itemchartprice.go`
|
||||||
|
- `services/worker/testdata/providers/kis/overseas_dailyprice_response.sample.json`
|
||||||
|
|
||||||
|
### 테스트 환경 규칙
|
||||||
|
|
||||||
|
`test_env=local`. worker-smoke가 적용된다. focused provider regression은 `go test -count=1 -run ... ./services/worker/internal/providers/kis`로 실행하고, worker surface confidence는 `go test -count=1 ./services/worker/...`, workspace confidence는 `bin/test`로 확인한다. live credential smoke는 범위 밖이다.
|
||||||
|
|
||||||
|
### 테스트 커버리지 공백
|
||||||
|
|
||||||
|
- `TestLiveProviderFetchesAndNormalizesOverseasDailyBars`와 `TestLiveProviderImportStoresUSDailyBars`는 overseas endpoint 사용과 US instrument/currency를 검증하지만, response에 `From` 이전 row가 있을 때 저장되지 않는지 검증하지 않는다.
|
||||||
|
- `fetchOverseasDailyBars`는 현재 `baseDate`만 받아 `From`을 필터링할 수 없다.
|
||||||
|
|
||||||
|
### 심볼 참조
|
||||||
|
|
||||||
|
Renamed/removed symbol: none. Expected changed call sites: `FetchDailyBars` -> `fetchOverseasDailyBars` signature, possible new date-range filter helper used only inside `services/worker/internal/providers/kis/live_client.go`.
|
||||||
|
|
||||||
|
### 분할 판단
|
||||||
|
|
||||||
|
이 작업은 기존 split subtask `02+01_kis_us_daily_bars`의 두 번째 review follow-up이다. Required 이슈가 하나이고 수정 범위가 `services/worker/internal/providers/kis` 내부 helper/test에 한정되어 새 subtask로 나누지 않는다.
|
||||||
|
|
||||||
|
### 범위 결정 근거
|
||||||
|
|
||||||
|
이 follow-up은 overseas dailyprice 결과의 date range 필터링만 다룬다. endpoint/TR_ID, EXCD mapping, US instrument name/asset type enrichment, shared query/backtest split은 변경하지 않는다.
|
||||||
|
|
||||||
|
### 빌드 등급
|
||||||
|
|
||||||
|
`local-G06`: 결함은 명확하고 deterministic unit test로 재현 가능하며 변경 범위가 provider helper와 test에 한정된다.
|
||||||
|
|
||||||
|
## 구현 체크리스트
|
||||||
|
|
||||||
|
- [ ] [REVIEW_REVIEW_KIS_US_DAILY-1] KIS overseas dailyprice 결과를 requested inclusive `From`/`To` local date range로 필터링하고, `From` 이전 row가 저장되지 않는 regression test를 추가한다.
|
||||||
|
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
|
### [REVIEW_REVIEW_KIS_US_DAILY-1] Overseas Daily Date Range Filter
|
||||||
|
|
||||||
|
문제: [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:394)는 `from`/`to`를 둘 다 계산하지만, US branch는 [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:404)에서 `to`만 넘긴다. [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:439)는 `BYMD` 기준 응답 row 전체를 반환하므로 `DailyBarRequest`의 inclusive date-range 계약을 깬다.
|
||||||
|
|
||||||
|
해결 방법:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Before
|
||||||
|
case market.VenueNASDAQ, market.VenueNYSE:
|
||||||
|
return p.fetchOverseasDailyBars(ctx, request.Selector.Venue, request.Selector.Symbols, to)
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// After
|
||||||
|
case market.VenueNASDAQ, market.VenueNYSE:
|
||||||
|
return p.fetchOverseasDailyBars(ctx, request.Selector.Venue, request.Selector.Symbols, from, to)
|
||||||
|
```
|
||||||
|
|
||||||
|
`fetchOverseasDailyBars`는 `fromDate`/`toDate` 문자열을 받아 `NormalizeOverseasDailyBars` 결과를 `bar.Timestamp.Format(kisDateLayout) >= fromDate && <= toDate`로 필터링한다. `time.Time` instant 비교만 쓰면 socket/job이 UTC midnight으로 만든 request와 New York midnight bar의 offset 차이 때문에 `To` 날짜 row를 잘못 버릴 수 있으므로, provider daily-bar 의미에 맞춰 venue-local date string으로 비교한다.
|
||||||
|
|
||||||
|
수정 파일 및 체크리스트:
|
||||||
|
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client.go`: `fetchOverseasDailyBars` signature를 `fromDate, toDate string` 포함으로 바꾸고 호출부를 갱신한다.
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client.go`: normalized overseas bars를 inclusive date string range로 필터링하는 helper를 추가한다.
|
||||||
|
- [ ] `services/worker/internal/providers/kis/live_client_test.go`: overseas response fixture를 test 안에서 확장해 `20240527` row를 추가하고, `From=20240528`, `To=20240529` import가 2 bars만 저장함을 검증하는 regression test를 추가한다.
|
||||||
|
- [ ] 기존 `TestLiveProviderFetchesAndNormalizesOverseasDailyBars`와 `TestLiveProviderImportStoresUSDailyBars`를 유지한다.
|
||||||
|
|
||||||
|
테스트 작성: 작성한다. 새 test 이름은 `TestLiveProviderFiltersOverseasDailyBarsToRequestedRange`로 둔다. fixture 파일 자체를 바꾸기보다 test-local response body를 만들어 기존 fixture + out-of-range row를 조합한다.
|
||||||
|
|
||||||
|
중간 검증:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 -run 'TestLiveProvider(FiltersOverseasDailyBarsToRequestedRange|FetchesAndNormalizesOverseasDailyBars|ImportStoresUSDailyBars)' ./services/worker/internal/providers/kis
|
||||||
|
```
|
||||||
|
|
||||||
|
## 수정 파일 요약
|
||||||
|
|
||||||
|
| 파일 | 항목 |
|
||||||
|
|---|---|
|
||||||
|
| `services/worker/internal/providers/kis/live_client.go` | REVIEW_REVIEW_KIS_US_DAILY-1 |
|
||||||
|
| `services/worker/internal/providers/kis/live_client_test.go` | REVIEW_REVIEW_KIS_US_DAILY-1 |
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -count=1 -run 'TestLiveProvider(FiltersOverseasDailyBarsToRequestedRange|FetchesAndNormalizesOverseasDailyBars|ImportStoresUSDailyBars)' ./services/worker/internal/providers/kis
|
||||||
|
go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket
|
||||||
|
go test -count=1 ./services/worker/...
|
||||||
|
bin/test
|
||||||
|
git diff --check
|
||||||
|
git diff --name-only -- '*.go' | xargs -r gofmt -l
|
||||||
|
```
|
||||||
|
|
||||||
|
`go test -count=1`은 fresh 실행을 요구한다. 마지막 `gofmt -l` 명령은 출력이 없어야 통과다.
|
||||||
|
|
||||||
|
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
<!-- task=m-us-market-expansion/02+01_kis_us_daily_bars plan=0 tag=KIS_US_DAILY -->
|
|
||||||
|
|
||||||
# Code Review Reference - KIS_US_DAILY
|
|
||||||
|
|
||||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
|
||||||
> Complete implementation-owned sections, then stop with active files in place and report ready for review. Do not finalize, archive, write `complete.log`, ask the user directly, or call `request_user_input`.
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
date=2026-06-06
|
|
||||||
task=m-us-market-expansion/02+01_kis_us_daily_bars, plan=0, tag=KIS_US_DAILY
|
|
||||||
|
|
||||||
## Roadmap Targets
|
|
||||||
|
|
||||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/us-market-expansion.md`
|
|
||||||
- Task ids:
|
|
||||||
- `us-daily-bars`: KIS를 초기 provider로 사용해 미국장 일봉 데이터를 normalized bar model로 저장
|
|
||||||
- Completion mode: check-on-pass
|
|
||||||
|
|
||||||
## 이 파일을 읽는 리뷰 에이전트에게
|
|
||||||
|
|
||||||
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
|
|
||||||
|
|
||||||
각 항목의 구현을 실제 소스 파일과 대조하고 검증 출력이 코드와 일치하는지 확인한다. PASS이면 완료 이벤트 메타데이터만 보고하고 roadmap 수정은 런타임 책임이다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 구현 항목별 완료 여부
|
|
||||||
|
|
||||||
| 항목 | 완료 여부 |
|
|
||||||
|------|---------|
|
|
||||||
| [KIS_US_DAILY-1] Provider Capability Gate | [ ] |
|
|
||||||
| [KIS_US_DAILY-2] KIS Overseas Daily Normalization | [ ] |
|
|
||||||
| [KIS_US_DAILY-3] Worker Import Path For US Watchlists | [ ] |
|
|
||||||
|
|
||||||
## 구현 체크리스트
|
|
||||||
|
|
||||||
- [ ] [KIS_US_DAILY-1] provider capability를 도입해 KIS가 KR/US daily bars 지원 범위를 선언하고 unsupported market/venue 조합을 import 전에 거절한다. 검증: 한국장 pipeline을 깨지 않고 provider capability가 미국장 daily bar import를 허용한다.
|
|
||||||
- [ ] [KIS_US_DAILY-2] KIS US daily bar fixture decoder/normalizer를 추가해 USD/NASDAQ/NYSE bar를 normalized model로 만든다.
|
|
||||||
- [ ] [KIS_US_DAILY-3] worker socket/job/importer 경로가 US watchlist import request를 KIS provider로 전달하고 instrument/bar upsert count를 보존한다.
|
|
||||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
|
||||||
|
|
||||||
## 코드리뷰 전용 체크리스트
|
|
||||||
|
|
||||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
|
||||||
|
|
||||||
- [ ] 판정을 append한다.
|
|
||||||
- [ ] active review/plan 파일을 `*.log`로 아카이브한다.
|
|
||||||
- [ ] `.gitignore` Agent-Ops block을 확인한다.
|
|
||||||
- [ ] PASS이면 `complete.log`를 작성하고 task directory를 archive로 이동한다.
|
|
||||||
- [ ] PASS이면 완료 이벤트 메타데이터를 보고한다.
|
|
||||||
- [ ] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다.
|
|
||||||
|
|
||||||
## 계획 대비 변경 사항
|
|
||||||
|
|
||||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
|
||||||
|
|
||||||
## 주요 설계 결정
|
|
||||||
|
|
||||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
|
||||||
|
|
||||||
## 사용자 리뷰 요청
|
|
||||||
|
|
||||||
- 상태: 없음
|
|
||||||
- 사유 유형: 없음
|
|
||||||
- 결정 필요: 없음
|
|
||||||
- 차단 근거: 없음
|
|
||||||
- 실행한 검증/명령: 없음
|
|
||||||
- 자동 후속 불가 이유: 없음
|
|
||||||
- 재개 조건: 없음
|
|
||||||
|
|
||||||
## 리뷰어를 위한 체크포인트
|
|
||||||
|
|
||||||
- provider capability가 `provider != market` 원칙을 지키는지 확인한다.
|
|
||||||
- KIS US fixture가 endpoint/TR_ID/field mapping을 test로 잠그는지 확인한다.
|
|
||||||
- KR existing daily import tests가 계속 통과하는지 확인한다.
|
|
||||||
- live secret 없이 deterministic fixture/unit 검증만으로 PASS를 주장하는지 확인한다.
|
|
||||||
|
|
||||||
## 검증 결과
|
|
||||||
|
|
||||||
### KIS_US_DAILY-1 중간 검증
|
|
||||||
```text
|
|
||||||
$ go test -count=1 ./packages/domain/... ./services/worker/internal/jobs
|
|
||||||
(output)
|
|
||||||
```
|
|
||||||
|
|
||||||
### KIS_US_DAILY-2 중간 검증
|
|
||||||
```text
|
|
||||||
$ go test -count=1 ./services/worker/internal/providers/kis
|
|
||||||
(output)
|
|
||||||
```
|
|
||||||
|
|
||||||
### KIS_US_DAILY-3 중간 검증
|
|
||||||
```text
|
|
||||||
$ go test -count=1 ./services/worker/internal/socket ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs
|
|
||||||
(output)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 최종 검증
|
|
||||||
```text
|
|
||||||
$ go test -count=1 ./packages/domain/...
|
|
||||||
(output)
|
|
||||||
|
|
||||||
$ go test -count=1 ./services/worker/internal/providers/kis ./services/worker/internal/marketdata/importer ./services/worker/internal/jobs ./services/worker/internal/socket
|
|
||||||
(output)
|
|
||||||
|
|
||||||
$ go test -count=1 ./services/worker/...
|
|
||||||
(output)
|
|
||||||
|
|
||||||
$ bin/test
|
|
||||||
(output)
|
|
||||||
|
|
||||||
$ git diff --check
|
|
||||||
(output)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package market
|
package market
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Market string
|
type Market string
|
||||||
|
|
||||||
|
|
@ -91,6 +94,46 @@ type Provider string
|
||||||
|
|
||||||
const ProviderKIS Provider = "kis"
|
const ProviderKIS Provider = "kis"
|
||||||
|
|
||||||
|
// ProviderCapability declares the venues and timeframes a market data provider
|
||||||
|
// can serve. It keeps provider distinct from market: one provider (KIS) can
|
||||||
|
// declare several venues across multiple markets, while a narrower future
|
||||||
|
// provider can declare KR-only coverage without changing the import boundary.
|
||||||
|
type ProviderCapability struct {
|
||||||
|
Provider Provider
|
||||||
|
Venues map[Venue]bool
|
||||||
|
Timeframes map[Timeframe]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportsDailyBars reports whether the capability covers daily bars for venue.
|
||||||
|
func (c ProviderCapability) SupportsDailyBars(venue Venue) bool {
|
||||||
|
return c.Venues[venue] && c.Timeframes[TimeframeDaily]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireDailyBars is the pre-import gate that rejects unsupported
|
||||||
|
// provider/market/venue combinations before any fetch. It validates that the
|
||||||
|
// request provider matches this capability, that the selector pins a venue this
|
||||||
|
// capability supports, that the selector market (when set) agrees with the
|
||||||
|
// venue's market, and that the capability covers daily bars for that venue.
|
||||||
|
func (c ProviderCapability) RequireDailyBars(provider Provider, selector UniverseSelector) error {
|
||||||
|
if provider != c.Provider {
|
||||||
|
return fmt.Errorf("provider %q does not match capability provider %q", provider, c.Provider)
|
||||||
|
}
|
||||||
|
if selector.Venue == "" {
|
||||||
|
return fmt.Errorf("provider %q daily bar import requires a venue", provider)
|
||||||
|
}
|
||||||
|
meta, ok := GetVenueMetadata(selector.Venue)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("provider %q does not support venue %q", provider, selector.Venue)
|
||||||
|
}
|
||||||
|
if selector.Market != "" && selector.Market != meta.Market {
|
||||||
|
return fmt.Errorf("selector market %q does not match venue %q market %q", selector.Market, selector.Venue, meta.Market)
|
||||||
|
}
|
||||||
|
if !c.SupportsDailyBars(selector.Venue) {
|
||||||
|
return fmt.Errorf("provider %q does not support daily bars for venue %q", provider, selector.Venue)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type UniverseSelectorKind string
|
type UniverseSelectorKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,58 @@ func TestMarketVenueMetadata(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderCapabilityRequireDailyBars(t *testing.T) {
|
||||||
|
// A KIS-like capability serving both KR and US daily bars.
|
||||||
|
kis := ProviderCapability{
|
||||||
|
Provider: ProviderKIS,
|
||||||
|
Venues: map[Venue]bool{
|
||||||
|
VenueKRX: true,
|
||||||
|
VenueNASDAQ: true,
|
||||||
|
VenueNYSE: true,
|
||||||
|
},
|
||||||
|
Timeframes: map[Timeframe]bool{TimeframeDaily: true},
|
||||||
|
}
|
||||||
|
// A narrower KR-only provider that must reject US venues.
|
||||||
|
krOnly := ProviderCapability{
|
||||||
|
Provider: Provider("kiwoom"),
|
||||||
|
Venues: map[Venue]bool{VenueKRX: true},
|
||||||
|
Timeframes: map[Timeframe]bool{TimeframeDaily: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
sel := func(market Market, venue Venue) UniverseSelector {
|
||||||
|
return UniverseSelector{Kind: UniverseSelectorWatchlist, Market: market, Venue: venue, Symbols: []string{"X"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cap ProviderCapability
|
||||||
|
provider Provider
|
||||||
|
selector UniverseSelector
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"kis allows KR/KRX", kis, ProviderKIS, sel(MarketKR, VenueKRX), false},
|
||||||
|
{"kis allows US/NASDAQ", kis, ProviderKIS, sel(MarketUS, VenueNASDAQ), false},
|
||||||
|
{"kis allows US/NYSE", kis, ProviderKIS, sel(MarketUS, VenueNYSE), false},
|
||||||
|
{"provider mismatch rejected", kis, Provider("yahoo"), sel(MarketUS, VenueNASDAQ), true},
|
||||||
|
{"missing venue rejected", kis, ProviderKIS, sel(MarketUS, ""), true},
|
||||||
|
{"unknown venue rejected", kis, ProviderKIS, sel(MarketUS, Venue("LSE")), true},
|
||||||
|
{"market/venue mismatch rejected", kis, ProviderKIS, sel(MarketKR, VenueNASDAQ), true},
|
||||||
|
{"kr-only capability rejects US", krOnly, Provider("kiwoom"), sel(MarketUS, VenueNASDAQ), true},
|
||||||
|
{"kr-only capability allows KR", krOnly, Provider("kiwoom"), sel(MarketKR, VenueKRX), false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
err := c.cap.RequireDailyBars(c.provider, c.selector)
|
||||||
|
if c.wantErr && err == nil {
|
||||||
|
t.Errorf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !c.wantErr && err != nil {
|
||||||
|
t.Errorf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAssetTypeVocabulary(t *testing.T) {
|
func TestAssetTypeVocabulary(t *testing.T) {
|
||||||
if string(AssetTypeEquity) != "equity" {
|
if string(AssetTypeEquity) != "equity" {
|
||||||
t.Errorf("AssetTypeEquity: got %q, want %q", string(AssetTypeEquity), "equity")
|
t.Errorf("AssetTypeEquity: got %q, want %q", string(AssetTypeEquity), "equity")
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.toki-labs.com/toki/alt/packages/domain/market"
|
|
||||||
workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest"
|
workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest"
|
||||||
"git.toki-labs.com/toki/alt/services/worker/internal/config"
|
"git.toki-labs.com/toki/alt/services/worker/internal/config"
|
||||||
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
|
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
|
||||||
|
|
@ -80,7 +79,7 @@ func storeBackedDeps(runner *jobs.Runner, store *postgres.Store) socket.Deps {
|
||||||
// import command so the operator-facing command and scheduled jobs share the
|
// import command so the operator-facing command and scheduled jobs share the
|
||||||
// same provider/store wiring.
|
// same provider/store wiring.
|
||||||
kisImporter := importer.New(kisProvider, store)
|
kisImporter := importer.New(kisProvider, store)
|
||||||
jobs.RegisterDailyBarImportHandler(runner, market.ProviderKIS, kisImporter)
|
jobs.RegisterDailyBarImportHandler(runner, kis.Capability(), kisImporter)
|
||||||
|
|
||||||
barSource := workerbacktest.NewStorageBarSource(store, store)
|
barSource := workerbacktest.NewStorageBarSource(store, store)
|
||||||
strategyPort := workerbacktest.NewBuiltInStrategyPort()
|
strategyPort := workerbacktest.NewBuiltInStrategyPort()
|
||||||
|
|
|
||||||
|
|
@ -100,17 +100,18 @@ func DecodeDailyBarImportPayload(raw json.RawMessage) (DailyBarImportPayload, er
|
||||||
|
|
||||||
// RegisterDailyBarImportHandler registers the real KindImportDailyBars handler,
|
// RegisterDailyBarImportHandler registers the real KindImportDailyBars handler,
|
||||||
// replacing the built-in placeholder. It decodes the payload into a daily bar
|
// replacing the built-in placeholder. It decodes the payload into a daily bar
|
||||||
// request, rejects payloads whose provider does not match expectedProvider, and
|
// request, gates it through the provider capability (rejecting provider
|
||||||
// dispatches to the importer. RegisterBuiltins still registers all placeholders
|
// mismatches and unsupported market/venue/daily-bar combinations before any
|
||||||
// first; callers invoke this afterwards to wire the live importer.
|
// fetch), and dispatches to the importer. RegisterBuiltins still registers all
|
||||||
func RegisterDailyBarImportHandler(runner *Runner, expectedProvider market.Provider, imp DailyBarImporter) {
|
// placeholders first; callers invoke this afterwards to wire the live importer.
|
||||||
|
func RegisterDailyBarImportHandler(runner *Runner, capability market.ProviderCapability, imp DailyBarImporter) {
|
||||||
runner.Register(KindImportDailyBars, func(ctx context.Context, payload json.RawMessage) error {
|
runner.Register(KindImportDailyBars, func(ctx context.Context, payload json.RawMessage) error {
|
||||||
p, err := DecodeDailyBarImportPayload(payload)
|
p, err := DecodeDailyBarImportPayload(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if market.Provider(p.Provider) != expectedProvider {
|
if err := capability.RequireDailyBars(market.Provider(p.Provider), p.selector()); err != nil {
|
||||||
return fmt.Errorf("daily bar import payload: provider %q does not match expected %q", p.Provider, expectedProvider)
|
return fmt.Errorf("daily bar import payload: %w", err)
|
||||||
}
|
}
|
||||||
req, err := p.request()
|
req, err := p.request()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,34 @@ func (s *stubImporter) ImportDailyBars(_ context.Context, request importer.Daily
|
||||||
return s.result, nil
|
return s.result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kisCapability mirrors the KIS provider capability the live worker wires in:
|
||||||
|
// KR/KRX plus the US venues, daily bars only.
|
||||||
|
func kisCapability() market.ProviderCapability {
|
||||||
|
return market.ProviderCapability{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Venues: map[market.Venue]bool{
|
||||||
|
market.VenueKRX: true,
|
||||||
|
market.VenueNASDAQ: true,
|
||||||
|
market.VenueNYSE: true,
|
||||||
|
},
|
||||||
|
Timeframes: map[market.Timeframe]bool{market.TimeframeDaily: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// krOnlyCapability is a stand-in for a future KR-only provider; it must reject
|
||||||
|
// US venue imports without any KR-specific code change.
|
||||||
|
func krOnlyCapability() market.ProviderCapability {
|
||||||
|
return market.ProviderCapability{
|
||||||
|
Provider: market.Provider("kiwoom"),
|
||||||
|
Venues: map[market.Venue]bool{market.VenueKRX: true},
|
||||||
|
Timeframes: map[market.Timeframe]bool{market.TimeframeDaily: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterDailyBarImportHandlerDispatchesImporter(t *testing.T) {
|
func TestRegisterDailyBarImportHandlerDispatchesImporter(t *testing.T) {
|
||||||
runner := NewRunner()
|
runner := NewRunner()
|
||||||
imp := &stubImporter{result: importer.Result{Instruments: 1, Bars: 2}}
|
imp := &stubImporter{result: importer.Result{Instruments: 1, Bars: 2}}
|
||||||
RegisterDailyBarImportHandler(runner, market.ProviderKIS, imp)
|
RegisterDailyBarImportHandler(runner, kisCapability(), imp)
|
||||||
|
|
||||||
payload := json.RawMessage(`{
|
payload := json.RawMessage(`{
|
||||||
"provider": "kis",
|
"provider": "kis",
|
||||||
|
|
@ -69,10 +93,37 @@ func TestRegisterDailyBarImportHandlerDispatchesImporter(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterDailyBarImportHandlerDispatchesUSWatchlist(t *testing.T) {
|
||||||
|
runner := NewRunner()
|
||||||
|
imp := &stubImporter{result: importer.Result{Instruments: 1, Bars: 2}}
|
||||||
|
RegisterDailyBarImportHandler(runner, kisCapability(), imp)
|
||||||
|
|
||||||
|
payload := json.RawMessage(`{
|
||||||
|
"provider": "kis",
|
||||||
|
"selector_kind": "watchlist",
|
||||||
|
"venue": "NASDAQ",
|
||||||
|
"market": "US",
|
||||||
|
"symbols": ["AAPL"],
|
||||||
|
"from": "20240527",
|
||||||
|
"to": "20240528"
|
||||||
|
}`)
|
||||||
|
|
||||||
|
job := Job{ID: "job-import-us", Kind: KindImportDailyBars, Payload: payload}
|
||||||
|
if err := runner.Execute(context.Background(), job); err != nil {
|
||||||
|
t.Fatalf("execute US import job: %v", err)
|
||||||
|
}
|
||||||
|
if !imp.called {
|
||||||
|
t.Fatal("expected importer to be dispatched for US watchlist, but it was not")
|
||||||
|
}
|
||||||
|
if imp.request.Selector.Market != market.MarketUS || imp.request.Selector.Venue != market.VenueNASDAQ {
|
||||||
|
t.Errorf("selector market/venue: got %q/%q, want US/NASDAQ", imp.request.Selector.Market, imp.request.Selector.Venue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterDailyBarImportHandlerRejectsProviderMismatch(t *testing.T) {
|
func TestRegisterDailyBarImportHandlerRejectsProviderMismatch(t *testing.T) {
|
||||||
runner := NewRunner()
|
runner := NewRunner()
|
||||||
imp := &stubImporter{}
|
imp := &stubImporter{}
|
||||||
RegisterDailyBarImportHandler(runner, market.ProviderKIS, imp)
|
RegisterDailyBarImportHandler(runner, kisCapability(), imp)
|
||||||
|
|
||||||
// Missing provider is rejected at decode.
|
// Missing provider is rejected at decode.
|
||||||
missing := Job{ID: "j", Kind: KindImportDailyBars, Payload: json.RawMessage(`{"selector_kind":"watchlist","symbols":["005930"]}`)}
|
missing := Job{ID: "j", Kind: KindImportDailyBars, Payload: json.RawMessage(`{"selector_kind":"watchlist","symbols":["005930"]}`)}
|
||||||
|
|
@ -80,14 +131,14 @@ func TestRegisterDailyBarImportHandlerRejectsProviderMismatch(t *testing.T) {
|
||||||
t.Error("expected error for missing provider, got nil")
|
t.Error("expected error for missing provider, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A provider that does not match the expected provider is rejected.
|
// A provider that does not match the capability provider is rejected.
|
||||||
mismatch := Job{ID: "j", Kind: KindImportDailyBars, Payload: json.RawMessage(`{"provider":"yahoo","selector_kind":"watchlist","symbols":["005930"]}`)}
|
mismatch := Job{ID: "j", Kind: KindImportDailyBars, Payload: json.RawMessage(`{"provider":"yahoo","selector_kind":"watchlist","venue":"KRX","market":"KR","symbols":["005930"]}`)}
|
||||||
if err := runner.Execute(context.Background(), mismatch); err == nil {
|
if err := runner.Execute(context.Background(), mismatch); err == nil {
|
||||||
t.Error("expected error for provider mismatch, got nil")
|
t.Error("expected error for provider mismatch, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// An invalid date is rejected before dispatch.
|
// An invalid date is rejected before dispatch (payload is otherwise valid).
|
||||||
badDate := Job{ID: "j", Kind: KindImportDailyBars, Payload: json.RawMessage(`{"provider":"kis","selector_kind":"watchlist","symbols":["005930"],"from":"2024-05-27"}`)}
|
badDate := Job{ID: "j", Kind: KindImportDailyBars, Payload: json.RawMessage(`{"provider":"kis","selector_kind":"watchlist","venue":"KRX","market":"KR","symbols":["005930"],"from":"2024-05-27"}`)}
|
||||||
if err := runner.Execute(context.Background(), badDate); err == nil {
|
if err := runner.Execute(context.Background(), badDate); err == nil {
|
||||||
t.Error("expected error for invalid from date, got nil")
|
t.Error("expected error for invalid from date, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -97,18 +148,58 @@ func TestRegisterDailyBarImportHandlerRejectsProviderMismatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterDailyBarImportHandlerRejectsUnsupportedVenue(t *testing.T) {
|
||||||
|
// A KR-only capability must reject a US venue without dispatching, and a KIS
|
||||||
|
// capability must reject an out-of-range venue.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cap market.ProviderCapability
|
||||||
|
payload string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "kr-only capability rejects US venue",
|
||||||
|
cap: krOnlyCapability(),
|
||||||
|
payload: `{"provider":"kiwoom","selector_kind":"watchlist","venue":"NASDAQ","market":"US","symbols":["AAPL"]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kis capability rejects unknown venue",
|
||||||
|
cap: kisCapability(),
|
||||||
|
payload: `{"provider":"kis","selector_kind":"watchlist","venue":"LSE","market":"US","symbols":["AAPL"]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kis capability rejects market/venue mismatch",
|
||||||
|
cap: kisCapability(),
|
||||||
|
payload: `{"provider":"kis","selector_kind":"watchlist","venue":"NASDAQ","market":"KR","symbols":["AAPL"]}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
runner := NewRunner()
|
||||||
|
imp := &stubImporter{}
|
||||||
|
RegisterDailyBarImportHandler(runner, c.cap, imp)
|
||||||
|
job := Job{ID: "j", Kind: KindImportDailyBars, Payload: json.RawMessage(c.payload)}
|
||||||
|
if err := runner.Execute(context.Background(), job); err == nil {
|
||||||
|
t.Error("expected error for unsupported venue, got nil")
|
||||||
|
}
|
||||||
|
if imp.called {
|
||||||
|
t.Error("importer must not be dispatched when capability rejects the request")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterDailyBarImportHandlerOverridesBuiltinPlaceholder(t *testing.T) {
|
func TestRegisterDailyBarImportHandlerOverridesBuiltinPlaceholder(t *testing.T) {
|
||||||
runner := NewRunner()
|
runner := NewRunner()
|
||||||
RegisterBuiltins(runner)
|
RegisterBuiltins(runner)
|
||||||
imp := &stubImporter{}
|
imp := &stubImporter{}
|
||||||
RegisterDailyBarImportHandler(runner, market.ProviderKIS, imp)
|
RegisterDailyBarImportHandler(runner, kisCapability(), imp)
|
||||||
|
|
||||||
// Overriding an existing kind must not add a new handler entry.
|
// Overriding an existing kind must not add a new handler entry.
|
||||||
if runner.Len() != 3 {
|
if runner.Len() != 3 {
|
||||||
t.Errorf("handler count after override: got %d, want 3", runner.Len())
|
t.Errorf("handler count after override: got %d, want 3", runner.Len())
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := json.RawMessage(`{"provider":"kis","selector_kind":"watchlist","symbols":["005930"]}`)
|
payload := json.RawMessage(`{"provider":"kis","selector_kind":"watchlist","venue":"KRX","market":"KR","symbols":["005930"]}`)
|
||||||
job := Job{ID: "job-import", Kind: KindImportDailyBars, Payload: payload}
|
job := Job{ID: "job-import", Kind: KindImportDailyBars, Payload: payload}
|
||||||
if err := runner.Execute(context.Background(), job); err != nil {
|
if err := runner.Execute(context.Background(), job); err != nil {
|
||||||
t.Fatalf("execute import job: %v", err)
|
t.Fatalf("execute import job: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,61 @@ func sampleDailyBars() []InstrumentBars {
|
||||||
return []InstrumentBars{{Instrument: inst, Bars: bars}}
|
return []InstrumentBars{{Instrument: inst, Bars: bars}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sampleUSDailyBars() []InstrumentBars {
|
||||||
|
ny := time.FixedZone("America/New_York", -4*60*60)
|
||||||
|
inst := market.Instrument{
|
||||||
|
ID: market.InstrumentID("NASDAQ:AAPL"),
|
||||||
|
Market: market.MarketUS,
|
||||||
|
Venue: market.VenueNASDAQ,
|
||||||
|
Symbol: "AAPL",
|
||||||
|
Name: "Apple Inc.",
|
||||||
|
Currency: market.CurrencyUSD,
|
||||||
|
AssetType: market.AssetTypeEquity,
|
||||||
|
ProviderSymbols: map[string]string{string(market.ProviderKIS): "AAPL"},
|
||||||
|
}
|
||||||
|
price := func(v string) market.Price {
|
||||||
|
return market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: v}}
|
||||||
|
}
|
||||||
|
bars := []market.Bar{
|
||||||
|
{
|
||||||
|
InstrumentID: inst.ID,
|
||||||
|
Timeframe: market.TimeframeDaily,
|
||||||
|
Timestamp: time.Date(2024, 5, 28, 0, 0, 0, 0, ny),
|
||||||
|
Open: price("191.51"),
|
||||||
|
High: price("193.00"),
|
||||||
|
Low: price("189.10"),
|
||||||
|
Close: price("189.99"),
|
||||||
|
Volume: market.Quantity{Amount: market.Decimal{Value: "52280051"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
InstrumentID: inst.ID,
|
||||||
|
Timeframe: market.TimeframeDaily,
|
||||||
|
Timestamp: time.Date(2024, 5, 29, 0, 0, 0, 0, ny),
|
||||||
|
Open: price("189.61"),
|
||||||
|
High: price("192.25"),
|
||||||
|
Low: price("189.51"),
|
||||||
|
Close: price("190.29"),
|
||||||
|
Volume: market.Quantity{Amount: market.Decimal{Value: "53068016"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return []InstrumentBars{{Instrument: inst, Bars: bars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usWatchlistRequest() DailyBarRequest {
|
||||||
|
ny := time.FixedZone("America/New_York", -4*60*60)
|
||||||
|
return DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{
|
||||||
|
Kind: market.UniverseSelectorWatchlist,
|
||||||
|
Market: market.MarketUS,
|
||||||
|
Venue: market.VenueNASDAQ,
|
||||||
|
Symbols: []string{"AAPL"},
|
||||||
|
},
|
||||||
|
From: time.Date(2024, 5, 28, 0, 0, 0, 0, ny),
|
||||||
|
To: time.Date(2024, 5, 29, 0, 0, 0, 0, ny),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func watchlistRequest() DailyBarRequest {
|
func watchlistRequest() DailyBarRequest {
|
||||||
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
|
seoul := time.FixedZone("Asia/Seoul", 9*60*60)
|
||||||
return DailyBarRequest{
|
return DailyBarRequest{
|
||||||
|
|
@ -175,6 +230,40 @@ func TestImporterStoresInstrumentAndBars(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImporterStoresUSInstrumentWithAssetType(t *testing.T) {
|
||||||
|
provider := &fakeProvider{items: sampleUSDailyBars()}
|
||||||
|
store := newMemStore()
|
||||||
|
imp := New(provider, store)
|
||||||
|
|
||||||
|
result, err := imp.ImportDailyBars(context.Background(), usWatchlistRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import US daily bars: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Instruments != 1 || result.Bars != 2 {
|
||||||
|
t.Errorf("result counts: got %d instruments / %d bars, want 1/2", result.Instruments, result.Bars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The US instrument must land with its USD currency and asset_type intact,
|
||||||
|
// so the storage upsert path carries instrument metadata, not just bars.
|
||||||
|
stored, ok := store.instruments[market.InstrumentID("NASDAQ:AAPL")]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected NASDAQ:AAPL instrument to be stored")
|
||||||
|
}
|
||||||
|
if stored.Market != market.MarketUS || stored.Venue != market.VenueNASDAQ {
|
||||||
|
t.Errorf("stored market/venue: got %q/%q, want US/NASDAQ", stored.Market, stored.Venue)
|
||||||
|
}
|
||||||
|
if stored.Currency != market.CurrencyUSD {
|
||||||
|
t.Errorf("stored currency: got %q, want USD", stored.Currency)
|
||||||
|
}
|
||||||
|
if stored.AssetType != market.AssetTypeEquity {
|
||||||
|
t.Errorf("stored asset type: got %q, want equity", stored.AssetType)
|
||||||
|
}
|
||||||
|
if len(store.bars) != 2 {
|
||||||
|
t.Errorf("stored bars: got %d, want 2", len(store.bars))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImporterIsIdempotentForSameDailyBars(t *testing.T) {
|
func TestImporterIsIdempotentForSameDailyBars(t *testing.T) {
|
||||||
provider := &fakeProvider{items: sampleDailyBars()}
|
provider := &fakeProvider{items: sampleDailyBars()}
|
||||||
store := newMemStore()
|
store := newMemStore()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
// tzdata is embedded so overseas venue timezones (e.g. America/New_York)
|
||||||
|
// resolve deterministically without depending on host zoneinfo being present.
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"git.toki-labs.com/toki/alt/packages/domain/market"
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
||||||
)
|
)
|
||||||
|
|
@ -14,8 +17,32 @@ import (
|
||||||
const (
|
const (
|
||||||
DailyItemChartPricePath = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
DailyItemChartPricePath = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||||
DailyItemChartPriceTRID = "FHKST03010100"
|
DailyItemChartPriceTRID = "FHKST03010100"
|
||||||
|
|
||||||
|
// OverseasDailyPricePath and OverseasDailyPriceTRID address the KIS overseas
|
||||||
|
// 기간별시세 (period price) endpoint used for individual US equities/ETFs. The
|
||||||
|
// inquire-daily-chartprice endpoint only covers index constituents, so the
|
||||||
|
// dailyprice endpoint is the one that resolves AAPL/SPY-style symbols.
|
||||||
|
OverseasDailyPricePath = "/uapi/overseas-price/v1/quotations/dailyprice"
|
||||||
|
OverseasDailyPriceTRID = "HHDFS76240000"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Capability declares the KIS provider's daily bar coverage. provider != market:
|
||||||
|
// KIS serves both the KR domestic venue (KRX) and the US venues (NASDAQ, NYSE),
|
||||||
|
// so the capability lists venues across markets rather than pinning one market.
|
||||||
|
func Capability() market.ProviderCapability {
|
||||||
|
return market.ProviderCapability{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Venues: map[market.Venue]bool{
|
||||||
|
market.VenueKRX: true,
|
||||||
|
market.VenueNASDAQ: true,
|
||||||
|
market.VenueNYSE: true,
|
||||||
|
},
|
||||||
|
Timeframes: map[market.Timeframe]bool{
|
||||||
|
market.TimeframeDaily: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// seoulOffset is the fixed KST offset. KRX has no DST, so a fixed +09:00 zone is
|
// seoulOffset is the fixed KST offset. KRX has no DST, so a fixed +09:00 zone is
|
||||||
// deterministic and avoids depending on tzdata being present at runtime.
|
// deterministic and avoids depending on tzdata being present at runtime.
|
||||||
const seoulOffsetSeconds = 9 * 60 * 60
|
const seoulOffsetSeconds = 9 * 60 * 60
|
||||||
|
|
@ -115,3 +142,120 @@ func NormalizeDailyBars(resp DailyItemChartPriceResponse, inst market.Instrument
|
||||||
}
|
}
|
||||||
return bars, nil
|
return bars, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OverseasDailyPriceRequest mirrors the KIS overseas dailyprice request shape
|
||||||
|
// captured in the worker test fixtures.
|
||||||
|
type OverseasDailyPriceRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
TrID string `json:"tr_id"`
|
||||||
|
Params map[string]string `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverseasDailyPriceResponse mirrors the KIS overseas dailyprice response. The
|
||||||
|
// overseas payload reuses the output1/output2 envelope but renames the OHLCV
|
||||||
|
// fields relative to the domestic chart response.
|
||||||
|
type OverseasDailyPriceResponse struct {
|
||||||
|
ReturnCode string `json:"rt_cd"`
|
||||||
|
MessageCd string `json:"msg_cd"`
|
||||||
|
Message string `json:"msg1"`
|
||||||
|
Output1 OverseasDailyPriceSummary `json:"output1"`
|
||||||
|
Output2 []OverseasDailyPriceBarRow `json:"output2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverseasDailyPriceSummary is the KIS overseas output1 metadata block.
|
||||||
|
type OverseasDailyPriceSummary struct {
|
||||||
|
RealtimeSymbol string `json:"rsym"`
|
||||||
|
DecimalDigits string `json:"zdiv"`
|
||||||
|
PrevClose string `json:"nrec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverseasDailyPriceBarRow is a single KIS overseas output2 OHLCV row. The
|
||||||
|
// overseas date field is xymd and the close field is clos, unlike the domestic
|
||||||
|
// stck_bsop_date / stck_clpr names.
|
||||||
|
type OverseasDailyPriceBarRow struct {
|
||||||
|
BusinessDate string `json:"xymd"`
|
||||||
|
Open string `json:"open"`
|
||||||
|
High string `json:"high"`
|
||||||
|
Low string `json:"low"`
|
||||||
|
Close string `json:"clos"`
|
||||||
|
Volume string `json:"tvol"`
|
||||||
|
TradeAmount string `json:"tamt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeOverseasDailyPriceRequest decodes a KIS overseas dailyprice request
|
||||||
|
// fixture.
|
||||||
|
func DecodeOverseasDailyPriceRequest(data []byte) (OverseasDailyPriceRequest, error) {
|
||||||
|
var req OverseasDailyPriceRequest
|
||||||
|
if err := json.Unmarshal(data, &req); err != nil {
|
||||||
|
return OverseasDailyPriceRequest{}, fmt.Errorf("decode kis overseas daily request: %w", err)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeOverseasDailyPriceResponse decodes a KIS overseas dailyprice response
|
||||||
|
// fixture and validates that it represents a successful payload with bar rows.
|
||||||
|
func DecodeOverseasDailyPriceResponse(data []byte) (OverseasDailyPriceResponse, error) {
|
||||||
|
var resp OverseasDailyPriceResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return OverseasDailyPriceResponse{}, fmt.Errorf("decode kis overseas daily response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.ReturnCode != "0" {
|
||||||
|
return OverseasDailyPriceResponse{}, fmt.Errorf("kis overseas daily response not successful: rt_cd=%q msg=%q", resp.ReturnCode, resp.Message)
|
||||||
|
}
|
||||||
|
if len(resp.Output2) == 0 {
|
||||||
|
return OverseasDailyPriceResponse{}, fmt.Errorf("kis overseas daily response has no output2 rows")
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeOverseasDailyBars converts KIS overseas output2 rows into ALT domain
|
||||||
|
// bars for the given instrument. Unlike the domestic normalizer it takes the
|
||||||
|
// timestamp timezone from the instrument's venue metadata (e.g.
|
||||||
|
// America/New_York for NASDAQ/NYSE) and the price currency from the instrument
|
||||||
|
// currency, so US bars land as USD bars at the venue's local midnight instead
|
||||||
|
// of inheriting the KRX/KRW domestic defaults.
|
||||||
|
func NormalizeOverseasDailyBars(resp OverseasDailyPriceResponse, inst market.Instrument) ([]market.Bar, error) {
|
||||||
|
currency := inst.Currency
|
||||||
|
loc, err := venueLocation(inst.Venue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bars := make([]market.Bar, 0, len(resp.Output2))
|
||||||
|
for i, row := range resp.Output2 {
|
||||||
|
ts, err := time.ParseInLocation(kisDateLayout, row.BusinessDate, loc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse overseas 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// venueLocation resolves the time zone a venue's daily bars are stamped in. KRX
|
||||||
|
// uses a fixed +09:00 zone (no DST) to stay independent of tzdata; other venues
|
||||||
|
// load their IANA timezone from the domain venue metadata.
|
||||||
|
func venueLocation(v market.Venue) (*time.Location, error) {
|
||||||
|
meta, ok := market.GetVenueMetadata(v)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown venue %q", v)
|
||||||
|
}
|
||||||
|
if meta.Venue == market.VenueKRX {
|
||||||
|
return time.FixedZone("Asia/Seoul", seoulOffsetSeconds), nil
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(meta.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load timezone %q for venue %q: %w", meta.Timezone, v, err)
|
||||||
|
}
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ type expectedDailyBarsFixture struct {
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
|
AssetType string `json:"asset_type"`
|
||||||
ProviderSymbols map[string]string `json:"provider_symbols"`
|
ProviderSymbols map[string]string `json:"provider_symbols"`
|
||||||
} `json:"instrument"`
|
} `json:"instrument"`
|
||||||
Bars []struct {
|
Bars []struct {
|
||||||
|
|
@ -72,11 +73,11 @@ type expectedDailyBarsFixture struct {
|
||||||
} `json:"bars"`
|
} `json:"bars"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeExpectedDailyBarsFixture(t *testing.T) expectedDailyBarsFixture {
|
func decodeExpectedDailyBarsFixture(t *testing.T, name string) expectedDailyBarsFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var fx expectedDailyBarsFixture
|
var fx expectedDailyBarsFixture
|
||||||
if err := json.Unmarshal(readFixture(t, "daily_bars_normalized.expected.json"), &fx); err != nil {
|
if err := json.Unmarshal(readFixture(t, name), &fx); err != nil {
|
||||||
t.Fatalf("decode expected bars fixture: %v", err)
|
t.Fatalf("decode expected bars fixture %s: %v", name, err)
|
||||||
}
|
}
|
||||||
return fx
|
return fx
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +90,7 @@ func (fx expectedDailyBarsFixture) instrument() market.Instrument {
|
||||||
Symbol: fx.Instrument.Symbol,
|
Symbol: fx.Instrument.Symbol,
|
||||||
Name: fx.Instrument.Name,
|
Name: fx.Instrument.Name,
|
||||||
Currency: market.Currency(fx.Instrument.Currency),
|
Currency: market.Currency(fx.Instrument.Currency),
|
||||||
|
AssetType: market.AssetType(fx.Instrument.AssetType),
|
||||||
ProviderSymbols: fx.Instrument.ProviderSymbols,
|
ProviderSymbols: fx.Instrument.ProviderSymbols,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +124,7 @@ func TestNormalizeDailyItemChartPriceBars(t *testing.T) {
|
||||||
t.Fatalf("decode response: %v", err)
|
t.Fatalf("decode response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := decodeExpectedDailyBarsFixture(t)
|
expected := decodeExpectedDailyBarsFixture(t, "daily_bars_normalized.expected.json")
|
||||||
inst := expected.instrument()
|
inst := expected.instrument()
|
||||||
|
|
||||||
// Lock the KRX daily defaults the fixture encodes before normalizing.
|
// Lock the KRX daily defaults the fixture encodes before normalizing.
|
||||||
|
|
@ -153,6 +155,83 @@ func TestNormalizeDailyItemChartPriceBars(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadOverseasDailyPriceFixture(t *testing.T) {
|
||||||
|
req, err := DecodeOverseasDailyPriceRequest(readFixture(t, "overseas_dailyprice_request.sample.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode overseas request: %v", err)
|
||||||
|
}
|
||||||
|
if req.Endpoint != "/uapi/overseas-price/v1/quotations/dailyprice" {
|
||||||
|
t.Errorf("overseas endpoint mismatch: got %q", req.Endpoint)
|
||||||
|
}
|
||||||
|
if req.TrID != "HHDFS76240000" {
|
||||||
|
t.Errorf("overseas tr_id mismatch: got %q", req.TrID)
|
||||||
|
}
|
||||||
|
if req.Params["EXCD"] != "NAS" || req.Params["SYMB"] != "AAPL" {
|
||||||
|
t.Errorf("overseas params mismatch: EXCD=%q SYMB=%q", req.Params["EXCD"], req.Params["SYMB"])
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DecodeOverseasDailyPriceResponse(readFixture(t, "overseas_dailyprice_response.sample.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode overseas response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Output2) != 2 {
|
||||||
|
t.Fatalf("overseas output2 row count: got %d, want 2", len(resp.Output2))
|
||||||
|
}
|
||||||
|
// Lock the overseas field renames (xymd/clos) the decoder must read.
|
||||||
|
if resp.Output2[0].BusinessDate != "20240528" {
|
||||||
|
t.Errorf("overseas xymd mismatch: got %q", resp.Output2[0].BusinessDate)
|
||||||
|
}
|
||||||
|
if resp.Output2[0].Close != "189.99" {
|
||||||
|
t.Errorf("overseas clos mismatch: got %q", resp.Output2[0].Close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOverseasDailyPriceBars(t *testing.T) {
|
||||||
|
resp, err := DecodeOverseasDailyPriceResponse(readFixture(t, "overseas_dailyprice_response.sample.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode overseas response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := decodeExpectedDailyBarsFixture(t, "overseas_daily_bars_normalized.expected.json")
|
||||||
|
inst := expected.instrument()
|
||||||
|
|
||||||
|
// Lock the US/NASDAQ defaults the fixture encodes before normalizing.
|
||||||
|
if inst.ID != market.InstrumentID("NASDAQ:AAPL") {
|
||||||
|
t.Errorf("fixture instrument id: got %q, want %q", inst.ID, "NASDAQ:AAPL")
|
||||||
|
}
|
||||||
|
if inst.Currency != market.CurrencyUSD {
|
||||||
|
t.Errorf("fixture instrument currency: got %q, want %q", inst.Currency, market.CurrencyUSD)
|
||||||
|
}
|
||||||
|
if inst.Venue != market.VenueNASDAQ {
|
||||||
|
t.Errorf("fixture instrument venue: got %q, want %q", inst.Venue, market.VenueNASDAQ)
|
||||||
|
}
|
||||||
|
if inst.AssetType != market.AssetTypeEquity {
|
||||||
|
t.Errorf("fixture instrument asset type: got %q, want %q", inst.AssetType, market.AssetTypeEquity)
|
||||||
|
}
|
||||||
|
|
||||||
|
bars, err := NormalizeOverseasDailyBars(resp, inst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize overseas 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bars must be USD at the venue's America/New_York midnight (EDT -04:00 in
|
||||||
|
// May), not the KRX/KRW domestic default.
|
||||||
|
if bars[0].Close.Currency != market.CurrencyUSD {
|
||||||
|
t.Errorf("bar currency: got %q, want USD", bars[0].Close.Currency)
|
||||||
|
}
|
||||||
|
if got := bars[0].Timestamp.Format(time.RFC3339); got != "2024-05-28T00:00:00-04:00" {
|
||||||
|
t.Errorf("bar timestamp offset: got %q, want %q", got, "2024-05-28T00:00:00-04:00")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertBarEqual(t *testing.T, idx int, got, want market.Bar) {
|
func assertBarEqual(t *testing.T, idx int, got, want market.Bar) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got.InstrumentID != want.InstrumentID {
|
if got.InstrumentID != want.InstrumentID {
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,98 @@ func (c *Client) InquireDailyItemChartPrice(ctx context.Context, accessToken str
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OverseasDailyPriceQuery describes a KIS overseas 기간별시세 (dailyprice) request.
|
||||||
|
// ExchangeCode is the KIS EXCD value (e.g. NAS, NYS), BaseDate is the BYMD
|
||||||
|
// reference date the endpoint walks back from, PeriodCode is GUBN (0=day), and
|
||||||
|
// Adjusted is MODP.
|
||||||
|
type OverseasDailyPriceQuery struct {
|
||||||
|
ExchangeCode string
|
||||||
|
Symbol string
|
||||||
|
BaseDate string
|
||||||
|
PeriodCode string
|
||||||
|
Adjusted string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q OverseasDailyPriceQuery) withDefaults() OverseasDailyPriceQuery {
|
||||||
|
if q.PeriodCode == "" {
|
||||||
|
q.PeriodCode = "0"
|
||||||
|
}
|
||||||
|
if q.Adjusted == "" {
|
||||||
|
q.Adjusted = "1"
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q OverseasDailyPriceQuery) validate() error {
|
||||||
|
if q.ExchangeCode == "" {
|
||||||
|
return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "exchange code is required"}
|
||||||
|
}
|
||||||
|
if q.Symbol == "" {
|
||||||
|
return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "symbol is required"}
|
||||||
|
}
|
||||||
|
if !isYYYYMMDD(q.BaseDate) {
|
||||||
|
return &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "base date must be YYYYMMDD"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchOverseasDailyPrice(ctx context.Context, query OverseasDailyPriceQuery) (OverseasDailyPriceResponse, error) {
|
||||||
|
token, err := c.Auth(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return OverseasDailyPriceResponse{}, err
|
||||||
|
}
|
||||||
|
return c.InquireOverseasDailyPrice(ctx, token.AccessToken, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) InquireOverseasDailyPrice(ctx context.Context, accessToken string, query OverseasDailyPriceQuery) (OverseasDailyPriceResponse, error) {
|
||||||
|
if err := c.validateBase("overseas-daily"); err != nil {
|
||||||
|
return OverseasDailyPriceResponse{}, err
|
||||||
|
}
|
||||||
|
if accessToken == "" {
|
||||||
|
return OverseasDailyPriceResponse{}, &Error{Kind: ErrorAuth, Op: "overseas-daily", Message: "missing access token"}
|
||||||
|
}
|
||||||
|
query = query.withDefaults()
|
||||||
|
if err := query.validate(); err != nil {
|
||||||
|
return OverseasDailyPriceResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"authorization": "Bearer " + accessToken,
|
||||||
|
"appkey": c.cfg.AppKey,
|
||||||
|
"appsecret": c.cfg.AppSecret,
|
||||||
|
"tr_id": OverseasDailyPriceTRID,
|
||||||
|
"custtype": "P",
|
||||||
|
"tr_cont": "",
|
||||||
|
}
|
||||||
|
params := url.Values{
|
||||||
|
"AUTH": []string{""},
|
||||||
|
"EXCD": []string{query.ExchangeCode},
|
||||||
|
"SYMB": []string{query.Symbol},
|
||||||
|
"GUBN": []string{query.PeriodCode},
|
||||||
|
"BYMD": []string{query.BaseDate},
|
||||||
|
"MODP": []string{query.Adjusted},
|
||||||
|
}
|
||||||
|
body, status, err := c.doJSON(ctx, http.MethodGet, OverseasDailyPricePath, headers, nil, params)
|
||||||
|
if err != nil {
|
||||||
|
return OverseasDailyPriceResponse{}, err
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return OverseasDailyPriceResponse{}, c.httpStatusError("overseas-daily", status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp OverseasDailyPriceResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return OverseasDailyPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "overseas-daily", Err: err}
|
||||||
|
}
|
||||||
|
if resp.ReturnCode != "0" {
|
||||||
|
return OverseasDailyPriceResponse{}, providerPayloadError("overseas-daily", resp.MessageCd, resp.Message)
|
||||||
|
}
|
||||||
|
if len(resp.Output2) == 0 {
|
||||||
|
return OverseasDailyPriceResponse{}, &Error{Kind: ErrorMalformed, Op: "overseas-daily", Message: "no output2 rows"}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
type LiveProvider struct {
|
type LiveProvider struct {
|
||||||
client *Client
|
client *Client
|
||||||
}
|
}
|
||||||
|
|
@ -301,8 +393,25 @@ func (p *LiveProvider) FetchDailyBars(ctx context.Context, request importer.Dail
|
||||||
|
|
||||||
from := request.From.Format(kisDateLayout)
|
from := request.From.Format(kisDateLayout)
|
||||||
to := request.To.Format(kisDateLayout)
|
to := request.To.Format(kisDateLayout)
|
||||||
items := make([]importer.InstrumentBars, 0, len(request.Selector.Symbols))
|
// Route by venue so a US request reaches the overseas endpoint instead of
|
||||||
for _, symbol := range request.Selector.Symbols {
|
// silently falling back to the KR domestic endpoint/normalizer. The provider
|
||||||
|
// rejects unsupported venues itself so a direct provider call cannot bypass
|
||||||
|
// the capability gate and store mislabeled bars.
|
||||||
|
switch request.Selector.Venue {
|
||||||
|
case "", market.VenueKRX:
|
||||||
|
return p.fetchDomesticDailyBars(ctx, request.Selector.Symbols, from, to)
|
||||||
|
case market.VenueNASDAQ, market.VenueNYSE:
|
||||||
|
return p.fetchOverseasDailyBars(ctx, request.Selector.Venue, request.Selector.Symbols, from, to)
|
||||||
|
default:
|
||||||
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported venue %q", request.Selector.Venue)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDomesticDailyBars fetches KR/KRX daily bars through the domestic chart
|
||||||
|
// endpoint and the KST normalizer.
|
||||||
|
func (p *LiveProvider) fetchDomesticDailyBars(ctx context.Context, symbols []string, from, to string) ([]importer.InstrumentBars, error) {
|
||||||
|
items := make([]importer.InstrumentBars, 0, len(symbols))
|
||||||
|
for _, symbol := range symbols {
|
||||||
resp, err := p.client.FetchDailyItemChartPrice(ctx, DailyItemChartPriceQuery{
|
resp, err := p.client.FetchDailyItemChartPrice(ctx, DailyItemChartPriceQuery{
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
From: from,
|
From: from,
|
||||||
|
|
@ -321,6 +430,81 @@ func (p *LiveProvider) FetchDailyBars(ctx context.Context, request importer.Dail
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchOverseasDailyBars fetches US daily bars through the overseas dailyprice
|
||||||
|
// endpoint and the venue-timezone normalizer. The overseas dailyprice base date
|
||||||
|
// (BYMD) is the request's To date; the endpoint walks back from it. The overseas
|
||||||
|
// payload carries no name or asset type, so the instrument lands with its
|
||||||
|
// venue-derived market/currency and the KIS provider symbol only; richer
|
||||||
|
// instrument metadata is a later split's concern.
|
||||||
|
func (p *LiveProvider) fetchOverseasDailyBars(ctx context.Context, venue market.Venue, symbols []string, fromDate, toDate string) ([]importer.InstrumentBars, error) {
|
||||||
|
exchangeCode, err := overseasExchangeCode(venue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]importer.InstrumentBars, 0, len(symbols))
|
||||||
|
for _, symbol := range symbols {
|
||||||
|
resp, err := p.client.FetchOverseasDailyPrice(ctx, OverseasDailyPriceQuery{
|
||||||
|
ExchangeCode: exchangeCode,
|
||||||
|
Symbol: symbol,
|
||||||
|
BaseDate: toDate,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inst, err := overseasInstrument(venue, symbol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bars, err := NormalizeOverseasDailyBars(resp, inst)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &Error{Kind: ErrorMalformed, Op: "provider", Message: "normalize overseas daily bars", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredBars := make([]market.Bar, 0, len(bars))
|
||||||
|
for _, bar := range bars {
|
||||||
|
barDate := bar.Timestamp.Format(kisDateLayout)
|
||||||
|
if barDate >= fromDate && barDate <= toDate {
|
||||||
|
filteredBars = append(filteredBars, bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, importer.InstrumentBars{Instrument: inst, Bars: filteredBars})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// overseasExchangeCode maps a US venue to the KIS overseas EXCD code.
|
||||||
|
func overseasExchangeCode(v market.Venue) (string, error) {
|
||||||
|
switch v {
|
||||||
|
case market.VenueNASDAQ:
|
||||||
|
return "NAS", nil
|
||||||
|
case market.VenueNYSE:
|
||||||
|
return "NYS", nil
|
||||||
|
default:
|
||||||
|
return "", &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unsupported overseas venue %q", v)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overseasInstrument builds the US instrument identity for an overseas symbol.
|
||||||
|
// Market and currency come from the venue metadata so the instrument cannot
|
||||||
|
// inherit KR/KRW defaults.
|
||||||
|
func overseasInstrument(venue market.Venue, symbol string) (market.Instrument, error) {
|
||||||
|
meta, ok := market.GetVenueMetadata(venue)
|
||||||
|
if !ok {
|
||||||
|
return market.Instrument{}, &Error{Kind: ErrorMalformed, Op: "provider", Message: fmt.Sprintf("unknown venue %q", venue)}
|
||||||
|
}
|
||||||
|
return market.Instrument{
|
||||||
|
ID: market.InstrumentID(string(venue) + ":" + symbol),
|
||||||
|
Market: meta.Market,
|
||||||
|
Venue: venue,
|
||||||
|
Symbol: symbol,
|
||||||
|
Currency: meta.Currency,
|
||||||
|
ProviderSymbols: map[string]string{
|
||||||
|
string(market.ProviderKIS): symbol,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func instrumentFromDailyResponse(resp DailyItemChartPriceResponse, requestedSymbol string) market.Instrument {
|
func instrumentFromDailyResponse(resp DailyItemChartPriceResponse, requestedSymbol string) market.Instrument {
|
||||||
symbol := resp.Output1.ShortCode
|
symbol := resp.Output1.ShortCode
|
||||||
if symbol == "" {
|
if symbol == "" {
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,203 @@ func TestLiveProviderFetchesAndNormalizesDailyBars(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInquireOverseasDailyPriceSendsHeadersAndDecodes(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != OverseasDailyPricePath {
|
||||||
|
t.Fatalf("unexpected path: got %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("authorization"); got != "Bearer access-token" {
|
||||||
|
t.Errorf("authorization header: got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("tr_id"); got != OverseasDailyPriceTRID {
|
||||||
|
t.Errorf("tr_id header: got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("custtype"); got != "P" {
|
||||||
|
t.Errorf("custtype header: got %q", got)
|
||||||
|
}
|
||||||
|
query := r.URL.Query()
|
||||||
|
wantParams := map[string]string{
|
||||||
|
"EXCD": "NAS",
|
||||||
|
"SYMB": "AAPL",
|
||||||
|
"GUBN": "0",
|
||||||
|
"BYMD": "20240529",
|
||||||
|
"MODP": "1",
|
||||||
|
}
|
||||||
|
for key, want := range wantParams {
|
||||||
|
if got := query.Get(key); got != want {
|
||||||
|
t.Errorf("query %s: got %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(readFixture(t, "overseas_dailyprice_response.sample.json"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
resp, err := client.InquireOverseasDailyPrice(context.Background(), "access-token", OverseasDailyPriceQuery{
|
||||||
|
ExchangeCode: "NAS",
|
||||||
|
Symbol: "AAPL",
|
||||||
|
BaseDate: "20240529",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overseas query: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Output2) != 2 {
|
||||||
|
t.Errorf("bar rows: got %d, want 2", len(resp.Output2))
|
||||||
|
}
|
||||||
|
// Lock the overseas field rename the decoder must read.
|
||||||
|
if resp.Output2[0].BusinessDate != "20240528" || resp.Output2[0].Close != "189.99" {
|
||||||
|
t.Errorf("overseas row decode: got date=%q clos=%q", resp.Output2[0].BusinessDate, resp.Output2[0].Close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveProviderFetchesAndNormalizesOverseasDailyBars(t *testing.T) {
|
||||||
|
var authCalls, overseasCalls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case tokenPath:
|
||||||
|
authCalls++
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
|
||||||
|
case OverseasDailyPricePath:
|
||||||
|
overseasCalls++
|
||||||
|
if got := r.URL.Query().Get("SYMB"); got != "AAPL" {
|
||||||
|
t.Errorf("symbol query: got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("EXCD"); got != "NAS" {
|
||||||
|
t.Errorf("exchange query: got %q", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(readFixture(t, "overseas_dailyprice_response.sample.json"))
|
||||||
|
case DailyItemChartPricePath:
|
||||||
|
t.Fatal("US request must not hit the domestic endpoint")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
provider := NewLiveProvider(client)
|
||||||
|
ny := time.FixedZone("America/New_York", -4*60*60)
|
||||||
|
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{
|
||||||
|
Kind: market.UniverseSelectorWatchlist,
|
||||||
|
Market: market.MarketUS,
|
||||||
|
Venue: market.VenueNASDAQ,
|
||||||
|
Symbols: []string{"AAPL"},
|
||||||
|
},
|
||||||
|
From: time.Date(2024, 5, 28, 0, 0, 0, 0, ny),
|
||||||
|
To: time.Date(2024, 5, 29, 0, 0, 0, 0, ny),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch overseas daily bars: %v", err)
|
||||||
|
}
|
||||||
|
if authCalls != 1 || overseasCalls != 1 {
|
||||||
|
t.Fatalf("calls: auth=%d overseas=%d, want 1 each", authCalls, overseasCalls)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items: got %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
inst := items[0].Instrument
|
||||||
|
if inst.ID != market.InstrumentID("NASDAQ:AAPL") {
|
||||||
|
t.Errorf("instrument id: got %q, want NASDAQ:AAPL", inst.ID)
|
||||||
|
}
|
||||||
|
if inst.Market != market.MarketUS || inst.Venue != market.VenueNASDAQ {
|
||||||
|
t.Errorf("instrument market/venue: got %q/%q, want US/NASDAQ", inst.Market, inst.Venue)
|
||||||
|
}
|
||||||
|
if inst.Currency != market.CurrencyUSD {
|
||||||
|
t.Errorf("instrument currency: got %q, want USD", inst.Currency)
|
||||||
|
}
|
||||||
|
if got := inst.ProviderSymbols[string(market.ProviderKIS)]; got != "AAPL" {
|
||||||
|
t.Errorf("provider symbol: got %q, want AAPL", got)
|
||||||
|
}
|
||||||
|
if len(items[0].Bars) != 2 {
|
||||||
|
t.Fatalf("bars: got %d, want 2", len(items[0].Bars))
|
||||||
|
}
|
||||||
|
bar := items[0].Bars[0]
|
||||||
|
if bar.Close.Currency != market.CurrencyUSD {
|
||||||
|
t.Errorf("bar currency: got %q, want USD", bar.Close.Currency)
|
||||||
|
}
|
||||||
|
if got := bar.Timestamp.Format(time.RFC3339); got != "2024-05-28T00:00:00-04:00" {
|
||||||
|
t.Errorf("bar timestamp: got %q, want New York midnight", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveProviderFiltersOverseasDailyBarsToRequestedRange(t *testing.T) {
|
||||||
|
var authCalls, overseasCalls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case tokenPath:
|
||||||
|
authCalls++
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
|
||||||
|
case OverseasDailyPricePath:
|
||||||
|
overseasCalls++
|
||||||
|
if got := r.URL.Query().Get("SYMB"); got != "AAPL" {
|
||||||
|
t.Errorf("symbol query: got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("EXCD"); got != "NAS" {
|
||||||
|
t.Errorf("exchange query: got %q", got)
|
||||||
|
}
|
||||||
|
var resp OverseasDailyPriceResponse
|
||||||
|
fixtureData := readFixture(t, "overseas_dailyprice_response.sample.json")
|
||||||
|
if err := json.Unmarshal(fixtureData, &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal fixture: %v", err)
|
||||||
|
}
|
||||||
|
resp.Output2 = append(resp.Output2, OverseasDailyPriceBarRow{
|
||||||
|
BusinessDate: "20240527",
|
||||||
|
Open: "188.00",
|
||||||
|
High: "189.00",
|
||||||
|
Low: "187.00",
|
||||||
|
Close: "188.50",
|
||||||
|
Volume: "40000000",
|
||||||
|
TradeAmount: "7500000000",
|
||||||
|
})
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
t.Fatalf("encode mock response: %v", err)
|
||||||
|
}
|
||||||
|
case DailyItemChartPricePath:
|
||||||
|
t.Fatal("US request must not hit the domestic endpoint")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
provider := NewLiveProvider(client)
|
||||||
|
ny := time.FixedZone("America/New_York", -4*60*60)
|
||||||
|
items, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{
|
||||||
|
Kind: market.UniverseSelectorWatchlist,
|
||||||
|
Market: market.MarketUS,
|
||||||
|
Venue: market.VenueNASDAQ,
|
||||||
|
Symbols: []string{"AAPL"},
|
||||||
|
},
|
||||||
|
From: time.Date(2024, 5, 28, 0, 0, 0, 0, ny),
|
||||||
|
To: time.Date(2024, 5, 29, 0, 0, 0, 0, ny),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch overseas daily bars: %v", err)
|
||||||
|
}
|
||||||
|
if authCalls != 1 || overseasCalls != 1 {
|
||||||
|
t.Fatalf("calls: auth=%d overseas=%d, want 1 each", authCalls, overseasCalls)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items: got %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
if len(items[0].Bars) != 2 {
|
||||||
|
t.Fatalf("bars: got %d, want 2", len(items[0].Bars))
|
||||||
|
}
|
||||||
|
for _, bar := range items[0].Bars {
|
||||||
|
barDate := bar.Timestamp.Format("20060102")
|
||||||
|
if barDate != "20240528" && barDate != "20240529" {
|
||||||
|
t.Errorf("unexpected bar date stored: %s", barDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLiveProviderRejectsMissingRange(t *testing.T) {
|
func TestLiveProviderRejectsMissingRange(t *testing.T) {
|
||||||
provider := NewLiveProvider(NewClient(Config{BaseURL: "https://example.invalid", AppKey: "app-key", AppSecret: "app-secret"}))
|
provider := NewLiveProvider(NewClient(Config{BaseURL: "https://example.invalid", AppKey: "app-key", AppSecret: "app-secret"}))
|
||||||
_, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
|
_, err := provider.FetchDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
|
@ -408,6 +605,69 @@ func TestLiveProviderImportFeedsBacktestBarSource(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLiveProviderImportStoresUSDailyBars(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case tokenPath:
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"access-token","access_token_token_expired":"2026-06-04 12:34:56"}`))
|
||||||
|
case OverseasDailyPricePath:
|
||||||
|
_, _ = w.Write(readFixture(t, "overseas_dailyprice_response.sample.json"))
|
||||||
|
case DailyItemChartPricePath:
|
||||||
|
t.Fatal("US import must not hit the domestic endpoint")
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(Config{BaseURL: server.URL, AppKey: "app-key", AppSecret: "app-secret"})
|
||||||
|
provider := NewLiveProvider(client)
|
||||||
|
store := newImportStore()
|
||||||
|
imp := importer.New(provider, store)
|
||||||
|
|
||||||
|
ny := time.FixedZone("America/New_York", -4*60*60)
|
||||||
|
from := time.Date(2024, 5, 28, 0, 0, 0, 0, ny)
|
||||||
|
to := time.Date(2024, 5, 29, 0, 0, 0, 0, ny)
|
||||||
|
result, err := imp.ImportDailyBars(context.Background(), importer.DailyBarRequest{
|
||||||
|
Provider: market.ProviderKIS,
|
||||||
|
Selector: market.UniverseSelector{
|
||||||
|
Kind: market.UniverseSelectorWatchlist,
|
||||||
|
Market: market.MarketUS,
|
||||||
|
Venue: market.VenueNASDAQ,
|
||||||
|
Symbols: []string{"AAPL"},
|
||||||
|
},
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import US daily bars: %v", err)
|
||||||
|
}
|
||||||
|
if result.Instruments != 1 || result.Bars != 2 {
|
||||||
|
t.Fatalf("import result: got %+v, want 1 instrument and 2 bars", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := store.GetInstrument(context.Background(), market.InstrumentID("NASDAQ:AAPL"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get stored instrument: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Market != market.MarketUS || stored.Venue != market.VenueNASDAQ || stored.Currency != market.CurrencyUSD {
|
||||||
|
t.Errorf("stored instrument: got market=%q venue=%q currency=%q, want US/NASDAQ/USD", stored.Market, stored.Venue, stored.Currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
bars, err := store.GetBars(context.Background(), market.InstrumentID("NASDAQ:AAPL"), market.TimeframeDaily, from.Add(-24*time.Hour), to.Add(24*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get stored bars: %v", err)
|
||||||
|
}
|
||||||
|
if len(bars) != 2 {
|
||||||
|
t.Fatalf("stored bars: got %d, want 2", len(bars))
|
||||||
|
}
|
||||||
|
for _, b := range bars {
|
||||||
|
if b.Close.Currency != market.CurrencyUSD {
|
||||||
|
t.Errorf("stored bar currency: got %q, want USD", b.Close.Currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLiveProviderPaperSmoke(t *testing.T) {
|
func TestLiveProviderPaperSmoke(t *testing.T) {
|
||||||
if os.Getenv("KIS_LIVE_SMOKE") != "1" {
|
if os.Getenv("KIS_LIVE_SMOKE") != "1" {
|
||||||
t.Skip("set KIS_LIVE_SMOKE=1 with SOPS-injected KIS paper env to run live smoke")
|
t.Skip("set KIS_LIVE_SMOKE=1 with SOPS-injected KIS paper env to run live smoke")
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,50 @@ func TestHandleImportDailyBarsConvertsRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validUSImportRequest() *altv1.ImportDailyBarsRequest {
|
||||||
|
return &altv1.ImportDailyBarsRequest{
|
||||||
|
Provider: "kis",
|
||||||
|
SelectorKind: "watchlist",
|
||||||
|
Market: altv1.Market_MARKET_US,
|
||||||
|
Venue: altv1.Venue_VENUE_NASDAQ,
|
||||||
|
Name: "us-watchlist",
|
||||||
|
Symbols: []string{"AAPL", "SPY"},
|
||||||
|
FromYyyymmdd: "20240528",
|
||||||
|
ToYyyymmdd: "20240529",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleImportDailyBarsConvertsUSRequest(t *testing.T) {
|
||||||
|
imp := &fakeDailyBarImporter{result: importer.Result{Instruments: 2, Bars: 4}}
|
||||||
|
|
||||||
|
resp, err := handleImportDailyBars(Deps{DailyBarImporter: imp}, validUSImportRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.GetError() != nil {
|
||||||
|
t.Fatalf("unexpected import error: %+v", resp.GetError())
|
||||||
|
}
|
||||||
|
if !imp.called {
|
||||||
|
t.Fatal("expected importer to be called for US watchlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The US market/venue enums must survive the proto -> domain conversion so
|
||||||
|
// the provider can resolve the right overseas exchange.
|
||||||
|
got := imp.gotReq
|
||||||
|
if got.Provider != market.ProviderKIS {
|
||||||
|
t.Errorf("provider mismatch: %q", got.Provider)
|
||||||
|
}
|
||||||
|
if got.Selector.Market != market.MarketUS || got.Selector.Venue != market.VenueNASDAQ {
|
||||||
|
t.Errorf("selector market/venue mismatch: %q/%q, want US/NASDAQ", got.Selector.Market, got.Selector.Venue)
|
||||||
|
}
|
||||||
|
if len(got.Selector.Symbols) != 2 {
|
||||||
|
t.Errorf("selector symbols: got %d, want 2", len(got.Selector.Symbols))
|
||||||
|
}
|
||||||
|
if resp.GetInstrumentCount() != 2 || resp.GetBarCount() != 4 {
|
||||||
|
t.Errorf("unexpected counts: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleImportDailyBarsValidation(t *testing.T) {
|
func TestHandleImportDailyBarsValidation(t *testing.T) {
|
||||||
// withImport starts from a fully valid request and mutates one field so each
|
// withImport starts from a fully valid request and mutates one field so each
|
||||||
// case isolates a single validation failure.
|
// case isolates a single validation failure.
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,12 @@ Postman exports are not required for the current milestone now that the official
|
||||||
|
|
||||||
Keep fixtures small, deterministic, and secret-free. The current minimum set is:
|
Keep fixtures small, deterministic, and secret-free. The current minimum set is:
|
||||||
|
|
||||||
- `daily_itemchartprice_request.sample.json`: KIS daily chart request parameters.
|
- `daily_itemchartprice_request.sample.json`: KIS domestic daily chart request parameters.
|
||||||
- `daily_itemchartprice_response.sample.json`: KIS-like response payload with `output1` metadata and `output2` OHLCV rows.
|
- `daily_itemchartprice_response.sample.json`: KIS-like domestic response payload with `output1` metadata and `output2` OHLCV rows.
|
||||||
- `daily_bars_normalized.expected.json`: expected normalized daily bars used by worker tests.
|
- `daily_bars_normalized.expected.json`: expected normalized KR/KRX daily bars used by worker tests.
|
||||||
|
- `overseas_dailyprice_request.sample.json`: KIS overseas `dailyprice` (해외주식 기간별시세) request parameters for a US symbol.
|
||||||
|
- `overseas_dailyprice_response.sample.json`: KIS-like overseas response payload with the `xymd`/`clos` field names the overseas endpoint uses.
|
||||||
|
- `overseas_daily_bars_normalized.expected.json`: expected normalized US/NASDAQ daily bars (USD, America/New_York timestamps).
|
||||||
- `provider_symbols.sample.json`: provider symbol metadata for watchlist, exchange-wide, and sector-like selector tests.
|
- `provider_symbols.sample.json`: provider symbol metadata for watchlist, exchange-wide, and sector-like selector tests.
|
||||||
|
|
||||||
## Sanitization
|
## Sanitization
|
||||||
|
|
|
||||||
38
services/worker/testdata/providers/kis/overseas_daily_bars_normalized.expected.json
vendored
Normal file
38
services/worker/testdata/providers/kis/overseas_daily_bars_normalized.expected.json
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"instrument": {
|
||||||
|
"id": "NASDAQ:AAPL",
|
||||||
|
"market": "US",
|
||||||
|
"venue": "NASDAQ",
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"name": "Apple Inc.",
|
||||||
|
"currency": "USD",
|
||||||
|
"asset_type": "equity",
|
||||||
|
"provider_symbols": {
|
||||||
|
"kis": "AAPL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bars": [
|
||||||
|
{
|
||||||
|
"instrument_id": "NASDAQ:AAPL",
|
||||||
|
"timeframe": "1d",
|
||||||
|
"timestamp": "2024-05-28T00:00:00-04:00",
|
||||||
|
"open": "191.51",
|
||||||
|
"high": "193.00",
|
||||||
|
"low": "189.10",
|
||||||
|
"close": "189.99",
|
||||||
|
"volume": "52280051",
|
||||||
|
"currency": "USD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"instrument_id": "NASDAQ:AAPL",
|
||||||
|
"timeframe": "1d",
|
||||||
|
"timestamp": "2024-05-29T00:00:00-04:00",
|
||||||
|
"open": "189.61",
|
||||||
|
"high": "192.25",
|
||||||
|
"low": "189.51",
|
||||||
|
"close": "190.29",
|
||||||
|
"volume": "53068016",
|
||||||
|
"currency": "USD"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
services/worker/testdata/providers/kis/overseas_dailyprice_request.sample.json
vendored
Normal file
13
services/worker/testdata/providers/kis/overseas_dailyprice_request.sample.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"provider": "kis",
|
||||||
|
"endpoint": "/uapi/overseas-price/v1/quotations/dailyprice",
|
||||||
|
"tr_id": "HHDFS76240000",
|
||||||
|
"params": {
|
||||||
|
"AUTH": "",
|
||||||
|
"EXCD": "NAS",
|
||||||
|
"SYMB": "AAPL",
|
||||||
|
"GUBN": "0",
|
||||||
|
"BYMD": "20240529",
|
||||||
|
"MODP": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
services/worker/testdata/providers/kis/overseas_dailyprice_response.sample.json
vendored
Normal file
44
services/worker/testdata/providers/kis/overseas_dailyprice_response.sample.json
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"rt_cd": "0",
|
||||||
|
"msg_cd": "MCA00000",
|
||||||
|
"msg1": "success",
|
||||||
|
"output1": {
|
||||||
|
"rsym": "DNASAAPL",
|
||||||
|
"zdiv": "2",
|
||||||
|
"nrec": "189.99"
|
||||||
|
},
|
||||||
|
"output2": [
|
||||||
|
{
|
||||||
|
"xymd": "20240528",
|
||||||
|
"clos": "189.99",
|
||||||
|
"sign": "5",
|
||||||
|
"diff": "0.50",
|
||||||
|
"rate": "-0.26",
|
||||||
|
"open": "191.51",
|
||||||
|
"high": "193.00",
|
||||||
|
"low": "189.10",
|
||||||
|
"tvol": "52280051",
|
||||||
|
"tamt": "9978000000",
|
||||||
|
"pbid": "189.98",
|
||||||
|
"vbid": "100",
|
||||||
|
"pask": "190.00",
|
||||||
|
"vask": "120"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"xymd": "20240529",
|
||||||
|
"clos": "190.29",
|
||||||
|
"sign": "2",
|
||||||
|
"diff": "0.30",
|
||||||
|
"rate": "0.16",
|
||||||
|
"open": "189.61",
|
||||||
|
"high": "192.25",
|
||||||
|
"low": "189.51",
|
||||||
|
"tvol": "53068016",
|
||||||
|
"tamt": "10120000000",
|
||||||
|
"pbid": "190.28",
|
||||||
|
"vbid": "150",
|
||||||
|
"pask": "190.30",
|
||||||
|
"vask": "140"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue