add worker data-check command and datacheck package, remove outdated plan/review docs

This commit is contained in:
toki 2026-05-29 20:52:32 +09:00
parent bee6dbb09a
commit 0bc2403b72
7 changed files with 271 additions and 209 deletions

View file

@ -1,87 +0,0 @@
<!-- task=m-korea-daily-data-foundation/03+01,02_data_check_smoke plan=0 tag=KIS_SMOKE -->
# Code Review Reference - KIS_SMOKE
> **[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.
> Finalization is review-agent-only.
## 개요
date=2026-05-29
task=m-korea-daily-data-foundation/03+01,02_data_check_smoke, plan=0, tag=KIS_SMOKE
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 종결 절차는 코드리뷰 에이전트 전용이다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [KIS_SMOKE-1] Mock Data Check Command | [ ] |
| [KIS_SMOKE-2] Whole Worker Regression | [ ] |
## 구현 체크리스트
- [ ] 선행 `01_provider_foundation``02+01_import_storage_pipeline``complete.log`를 확인한다.
- [ ] mock fixture import와 normalized bar 조회를 실행하는 CLI 또는 worker command를 추가한다.
- [ ] command output이 instrument count, bar count, first/last timestamp를 안정적으로 출력하도록 테스트한다.
- [ ] `bin/test` 또는 focused worker command가 credential 없이 통과함을 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 구현 에이전트는 수정하지 않는다.
- [ ] `코드리뷰 결과`에 판정을 append한다.
- [ ] active plan/review를 log로 아카이브한다.
- [ ] PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 `USER_REVIEW.md`를 작성한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 실제 KIS credential/1Password가 smoke command에 필요하지 않은지 확인한다.
- stdout contract가 안정적이고 테스트로 고정되는지 확인한다.
- `bin/test` 결과가 실제 출력으로 기록되는지 확인한다.
## 검증 결과
### KIS_SMOKE-1 중간 검증
```bash
$ cd apps/cli && go test -count=1 ./...
```
### KIS_SMOKE-2 중간 검증
```bash
$ bin/test
```
### 최종 검증
```bash
$ test -f agent-task/m-korea-daily-data-foundation/01_provider_foundation/complete.log
$ test -f agent-task/m-korea-daily-data-foundation/02+01_import_storage_pipeline/complete.log
$ bin/test
```

View file

@ -1,122 +0,0 @@
<!-- task=m-korea-daily-data-foundation/03+01,02_data_check_smoke plan=0 tag=KIS_SMOKE -->
# Plan - Mock Data Check Smoke
## 이 파일을 읽는 구현 에이전트에게
이 subtask는 `01_provider_foundation``02+01_import_storage_pipeline` 완료 후 시작한다. 구현 완료 전 active review stub의 구현 에이전트 소유 섹션을 채운다. 실제 KIS credential이나 1Password 준비를 요구하지 않는다.
## 배경
Milestone의 `data-check`는 normalized data가 backtest milestone에서 소비 가능한 형태로 조회되고 관련 검증이 통과해야 한다. 현재는 mock fixture 기반 흐름을 사람이 반복 실행할 수 있는 smoke 표면이 없다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active `CODE_REVIEW-cloud-G07.md``사용자 리뷰 요청` 섹션에 기록한다. code-review가 검증과 종료 처리를 소유한다.
## 분석 결과
### 읽은 파일
- `bin/dev`
- `bin/test`
- `bin/worker-storage-check`
- `apps/cli/cmd/alt/main.go`
- `services/worker/cmd/alt-worker/main.go`
- `services/worker/internal/jobs/builtin.go`
- `services/worker/testdata/providers/kis/*.json`
- `agent-task/m-korea-daily-data-foundation/01_provider_foundation/PLAN-cloud-G07.md`
- `agent-task/m-korea-daily-data-foundation/02+01_import_storage_pipeline/PLAN-cloud-G07.md`
### 테스트 커버리지 공백
- End-to-end mock import command: 기존 없음. CLI 또는 worker command test 필요.
- Human-runnable data check output: 기존 없음. stdout contract test 필요.
- Full worker package regression after import pipeline: 기존 `bin/test`는 있으나 feature-specific smoke 없음.
### 심볼 참조
none. 기존 `bin/test`와 storage check는 유지한다.
### 분할 판단
`03+01,02_data_check_smoke`는 directory name상 `01_provider_foundation``02+01_import_storage_pipeline`에 의존한다. Smoke command는 실제 provider/import API가 완료된 뒤 작성해야 한다.
### 범위 결정 근거
실제 KIS network smoke, 1Password credential injection, Postgres Docker orchestration은 제외한다. 이 작업은 mock fixture를 읽어 normalized daily bar 조회 가능성을 보여주는 local command/test만 만든다.
### 빌드 등급
build=`cloud-G07`, review=`cloud-G07`. terminal-facing smoke output과 bin/CLI behavior가 포함된다.
## 의존 관계 및 구현 순서
1. `agent-task/m-korea-daily-data-foundation/01_provider_foundation/complete.log` 확인
2. `agent-task/m-korea-daily-data-foundation/02+01_import_storage_pipeline/complete.log` 확인
3. mock smoke command 구현
4. full verification 실행
## 구현 체크리스트
- [ ] 선행 `01_provider_foundation``02+01_import_storage_pipeline``complete.log`를 확인한다.
- [ ] mock fixture import와 normalized bar 조회를 실행하는 CLI 또는 worker command를 추가한다.
- [ ] command output이 instrument count, bar count, first/last timestamp를 안정적으로 출력하도록 테스트한다.
- [ ] `bin/test` 또는 focused worker command가 credential 없이 통과함을 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [KIS_SMOKE-1] Mock Data Check Command
문제: [apps/cli main](/config/workspace/alt/apps/cli/cmd/alt/main.go:8)은 `version` 외에 운영자 command가 없고, worker import 결과를 재현 가능하게 확인할 표면도 없다.
해결 방법: 가장 작은 표면으로 `apps/cli` 또는 `services/worker/cmd`에 mock data check command를 추가한다. command는 KIS fixture를 import pipeline에 넣고 in-memory store에서 bars를 조회한 뒤 stable summary를 출력한다.
수정 파일 및 체크리스트:
- [ ] `apps/cli/cmd/alt/main.go` 또는 새 `services/worker/cmd/alt-worker-data-check/main.go` 추가/수정
- [ ] command test 추가
테스트 작성: stdout에 `instrument_count=1`, `bar_count=2`, `first=2024-05-27`, `last=2024-05-28`이 있는지 확인한다.
중간 검증:
```bash
cd apps/cli && go test -count=1 ./...
```
### [KIS_SMOKE-2] Whole Worker Regression
문제: mock provider/import/smoke가 여러 package를 가로지른 뒤 worker 전체 regression 명령이 명시되어 있지 않다.
해결 방법: focused tests와 `bin/test`를 모두 실행한다. sqlc generated drift는 storage query 변경이 있을 때만 `bin/worker-storage-check`를 추가 실행한다.
수정 파일 및 체크리스트:
- [ ] smoke command 구현 후 `bin/test` 통과 확인
- [ ] storage query/migration 변경이 있었다면 `bin/worker-storage-check` 실행
테스트 작성: 별도 새 테스트는 KIS_SMOKE-1에 포함한다.
중간 검증:
```bash
bin/test
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/cli/cmd/alt/main.go` 또는 `services/worker/cmd/alt-worker-data-check/main.go` | KIS_SMOKE-1 |
| command test file | KIS_SMOKE-1 |
| `bin/*` only if an entrypoint is needed | KIS_SMOKE-2 |
## 최종 검증
```bash
test -f agent-task/m-korea-daily-data-foundation/01_provider_foundation/complete.log
test -f agent-task/m-korea-daily-data-foundation/02+01_import_storage_pipeline/complete.log
bin/test
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,21 @@
// Command alt-worker-data-check runs the Korea daily data foundation smoke: it
// imports an embedded KIS daily-chart fixture through the normalize/import
// pipeline into an in-memory store and prints a stable summary of the bars
// queried back. It needs no KIS credential, 1Password, or PostgreSQL, so an
// operator can re-run it locally to confirm normalized daily bars are queryable.
package main
import (
"context"
"fmt"
"os"
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck"
)
func main() {
if err := datacheck.Run(context.Background(), os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View file

@ -0,0 +1,108 @@
// Package datacheck provides a credential-free smoke surface for the Korea
// daily data foundation: it feeds an embedded KIS daily-chart fixture through
// the real decode/normalize and import pipeline into an in-memory store, then
// queries the stored bars back and prints a stable one-line summary. It proves
// normalized daily bars are queryable in the shape the backtest milestone will
// consume, without any live KIS credential, 1Password, or PostgreSQL.
package datacheck
import (
"context"
_ "embed"
"fmt"
"io"
"sort"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
"git.toki-labs.com/toki/alt/services/worker/internal/providers/kis"
)
// dailyChartResponseFixture is a self-contained copy of the KIS daily chart
// response fixture. It is embedded here (rather than read from the module's
// testdata tree) so the smoke command builds and runs as a normal binary: Go
// build tooling ignores testdata, and go:embed cannot reach across module
// directories with "..".
//
//go:embed fixtures/daily_itemchartprice_response.sample.json
var dailyChartResponseFixture []byte
// fixtureProvider implements importer.DailyBarProvider by normalizing the
// embedded KIS fixture. It exercises the real kis decode/normalize path instead
// of fabricating domain bars, so the smoke covers provider normalization too.
type fixtureProvider struct{}
func (fixtureProvider) FetchDailyBars(_ context.Context, _ importer.DailyBarRequest) ([]importer.InstrumentBars, error) {
resp, err := kis.DecodeDailyItemChartPriceResponse(dailyChartResponseFixture)
if err != nil {
return nil, fmt.Errorf("datacheck: decode fixture: %w", err)
}
inst := market.Instrument{
ID: market.InstrumentID("KRX:" + resp.Output1.ShortCode),
Market: market.MarketKR,
Venue: market.VenueKRX,
Symbol: resp.Output1.ShortCode,
Name: resp.Output1.Name,
Currency: market.CurrencyKRW,
ProviderSymbols: map[string]string{string(market.ProviderKIS): resp.Output1.ShortCode},
}
bars, err := kis.NormalizeDailyBars(resp, inst)
if err != nil {
return nil, fmt.Errorf("datacheck: normalize bars: %w", err)
}
return []importer.InstrumentBars{{Instrument: inst, Bars: bars}}, nil
}
// request mirrors the watchlist daily bar request a real import job would carry.
func request() importer.DailyBarRequest {
return importer.DailyBarRequest{
Provider: market.ProviderKIS,
Selector: market.UniverseSelector{
Kind: market.UniverseSelectorWatchlist,
Venue: market.VenueKRX,
Symbols: []string{"005930"},
},
}
}
// Run executes the mock data check and writes a stable summary line to w. The
// summary reports the instrument count, total daily bar count, and the first
// and last bar dates (YYYY-MM-DD) queried back from the store.
func Run(ctx context.Context, w io.Writer) error {
store := newMemStore()
imp := importer.New(fixtureProvider{}, store)
if _, err := imp.ImportDailyBars(ctx, request()); err != nil {
return fmt.Errorf("datacheck: import: %w", err)
}
instruments, err := store.ListInstruments(ctx)
if err != nil {
return fmt.Errorf("datacheck: list instruments: %w", err)
}
// Query daily bars back over an unbounded range so the summary reflects what
// a backtest consumer would read, not just what the importer reported.
var bars []market.Bar
for _, inst := range instruments {
got, err := store.GetBars(ctx, inst.ID, market.TimeframeDaily, time.Time{}, time.Unix(1<<62, 0))
if err != nil {
return fmt.Errorf("datacheck: get bars for %q: %w", inst.ID, err)
}
bars = append(bars, got...)
}
sort.Slice(bars, func(i, j int) bool { return bars[i].Timestamp.Before(bars[j].Timestamp) })
first, last := "none", "none"
if len(bars) > 0 {
first = bars[0].Timestamp.Format("2006-01-02")
last = bars[len(bars)-1].Timestamp.Format("2006-01-02")
}
if _, err := fmt.Fprintf(w, "instrument_count=%d bar_count=%d first=%s last=%s\n",
len(instruments), len(bars), first, last); err != nil {
return fmt.Errorf("datacheck: write summary: %w", err)
}
return nil
}

View file

@ -0,0 +1,31 @@
package datacheck
import (
"bytes"
"context"
"strings"
"testing"
)
// TestRunPrintsStableSummary pins the stdout contract the smoke command exposes
// to operators: importing the embedded KIS fixture and querying bars back must
// always report one Samsung instrument with two daily bars spanning 2024-05-27
// to 2024-05-28, with no credential or external service.
func TestRunPrintsStableSummary(t *testing.T) {
var buf bytes.Buffer
if err := Run(context.Background(), &buf); err != nil {
t.Fatalf("Run: %v", err)
}
got := buf.String()
for _, want := range []string{
"instrument_count=1",
"bar_count=2",
"first=2024-05-27",
"last=2024-05-28",
} {
if !strings.Contains(got, want) {
t.Errorf("summary %q missing %q", strings.TrimSpace(got), want)
}
}
}

View file

@ -0,0 +1,43 @@
{
"rt_cd": "0",
"msg_cd": "MCA00000",
"msg1": "success",
"output1": {
"hts_kor_isnm": "Samsung Electronics",
"stck_shrn_iscd": "005930",
"stck_prpr": "76000",
"prdy_vrss": "1000",
"prdy_vrss_sign": "2",
"prdy_ctrt": "1.33",
"acml_vol": "10500000",
"acml_tr_pbmn": "798000000000"
},
"output2": [
{
"stck_bsop_date": "20240527",
"stck_oprc": "74800",
"stck_hgpr": "75600",
"stck_lwpr": "74400",
"stck_clpr": "75000",
"acml_vol": "9100000",
"acml_tr_pbmn": "682500000000",
"flng_cls_code": "00",
"prtt_rate": "0.00",
"mod_yn": "N",
"revl_issu_reas": ""
},
{
"stck_bsop_date": "20240528",
"stck_oprc": "75200",
"stck_hgpr": "76200",
"stck_lwpr": "75100",
"stck_clpr": "76000",
"acml_vol": "10500000",
"acml_tr_pbmn": "798000000000",
"flng_cls_code": "00",
"prtt_rate": "0.00",
"mod_yn": "N",
"revl_issu_reas": ""
}
]
}

View file

@ -0,0 +1,68 @@
package datacheck
import (
"context"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
// barKey mirrors the bars primary key (instrument_id, timeframe, timestamp) so
// the in-memory store replaces rows the same way the PostgreSQL UpsertBar does,
// keeping the smoke's idempotency behavior aligned with real storage.
type barKey struct {
instrumentID market.InstrumentID
timeframe market.Timeframe
timestamp time.Time
}
// memStore is an in-memory InstrumentStore + BarStore for the smoke command. It
// satisfies importer.Stores without any external dependency so the data check
// runs with no PostgreSQL or credential.
type memStore struct {
instruments map[market.InstrumentID]market.Instrument
bars map[barKey]market.Bar
}
func newMemStore() *memStore {
return &memStore{
instruments: make(map[market.InstrumentID]market.Instrument),
bars: make(map[barKey]market.Bar),
}
}
func (s *memStore) UpsertInstrument(_ context.Context, inst market.Instrument) error {
s.instruments[inst.ID] = inst
return nil
}
func (s *memStore) GetInstrument(_ context.Context, id market.InstrumentID) (market.Instrument, error) {
return s.instruments[id], nil
}
func (s *memStore) ListInstruments(_ context.Context) ([]market.Instrument, error) {
out := make([]market.Instrument, 0, len(s.instruments))
for _, inst := range s.instruments {
out = append(out, inst)
}
return out, nil
}
func (s *memStore) UpsertBar(_ context.Context, bar market.Bar) error {
s.bars[barKey{bar.InstrumentID, bar.Timeframe, bar.Timestamp}] = bar
return nil
}
func (s *memStore) GetBars(_ context.Context, id market.InstrumentID, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
out := make([]market.Bar, 0)
for k, bar := range s.bars {
if k.instrumentID != id || k.timeframe != timeframe {
continue
}
if k.timestamp.Before(from) || k.timestamp.After(to) {
continue
}
out = append(out, bar)
}
return out, nil
}