diff --git a/agent-task/m-korea-daily-data-foundation/03+01,02_data_check_smoke/CODE_REVIEW-cloud-G07.md b/agent-task/m-korea-daily-data-foundation/03+01,02_data_check_smoke/CODE_REVIEW-cloud-G07.md deleted file mode 100644 index 7a1db02..0000000 --- a/agent-task/m-korea-daily-data-foundation/03+01,02_data_check_smoke/CODE_REVIEW-cloud-G07.md +++ /dev/null @@ -1,87 +0,0 @@ - - -# 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-`이면 완료 이벤트 메타데이터를 보고한다. -- [ ] 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 -``` diff --git a/agent-task/m-korea-daily-data-foundation/03+01,02_data_check_smoke/PLAN-cloud-G07.md b/agent-task/m-korea-daily-data-foundation/03+01,02_data_check_smoke/PLAN-cloud-G07.md deleted file mode 100644 index e3c10fa..0000000 --- a/agent-task/m-korea-daily-data-foundation/03+01,02_data_check_smoke/PLAN-cloud-G07.md +++ /dev/null @@ -1,122 +0,0 @@ - - -# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/worker/cmd/alt-worker-data-check/main.go b/services/worker/cmd/alt-worker-data-check/main.go new file mode 100644 index 0000000..7280ee1 --- /dev/null +++ b/services/worker/cmd/alt-worker-data-check/main.go @@ -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) + } +} diff --git a/services/worker/internal/marketdata/datacheck/datacheck.go b/services/worker/internal/marketdata/datacheck/datacheck.go new file mode 100644 index 0000000..26b64e2 --- /dev/null +++ b/services/worker/internal/marketdata/datacheck/datacheck.go @@ -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 +} diff --git a/services/worker/internal/marketdata/datacheck/datacheck_test.go b/services/worker/internal/marketdata/datacheck/datacheck_test.go new file mode 100644 index 0000000..bbacdda --- /dev/null +++ b/services/worker/internal/marketdata/datacheck/datacheck_test.go @@ -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) + } + } +} diff --git a/services/worker/internal/marketdata/datacheck/fixtures/daily_itemchartprice_response.sample.json b/services/worker/internal/marketdata/datacheck/fixtures/daily_itemchartprice_response.sample.json new file mode 100644 index 0000000..cf801d9 --- /dev/null +++ b/services/worker/internal/marketdata/datacheck/fixtures/daily_itemchartprice_response.sample.json @@ -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": "" + } + ] +} diff --git a/services/worker/internal/marketdata/datacheck/store.go b/services/worker/internal/marketdata/datacheck/store.go new file mode 100644 index 0000000..69c503a --- /dev/null +++ b/services/worker/internal/marketdata/datacheck/store.go @@ -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 +}