diff --git a/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/code_review_cloud_G08_0.log b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/code_review_cloud_G08_0.log new file mode 100644 index 0000000..ebd9d6f --- /dev/null +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/code_review_cloud_G08_0.log @@ -0,0 +1,222 @@ + + +# Code Review Reference - API + +> **[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. +> 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-03 +task=m-command-first-operator-workflow/01_import_contract_worker_api, plan=0, tag=API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/01_import_contract_worker_api/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Contract And Parser Surface | [x] | +| [API-2] API Forwarding | [x] | +| [API-3] Worker Import Handler | [x] | +| [API-4] Worker Entrypoint Wiring | [x] | +| [API-5] Focused Verification | [x] | + +## 구현 체크리스트 + +- [x] [API-1] Add `ImportDailyBarsRequest/Response` protobuf messages, regenerate contracts, and register parsers in API/worker/CLI maps. +- [x] [API-2] Add API workerclient and API socket forwarding for import daily bars with typed unavailable/timeout/internal errors. +- [x] [API-3] Add worker socket import handler that validates request shape, calls the importer dependency, and returns instrument/bar counts. +- [x] [API-4] Wire `alt-worker` store-backed deps with the KIS importer command surface without exposing secrets. +- [x] [API-5] Run focused contract/API/worker verification and `git diff --check`. +- [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_G08_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/`를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/01_import_contract_worker_api/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-command-first-operator-workflow/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.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로 이동한다. + +## 계획 대비 변경 사항 + +- 검증 명령은 계획대로 실행했다. 단, `bin/contracts-gen`/`bin/contracts-check`는 committed 생성물이 protoc v5.29.3(=protoc 29.3)으로 생성되어 있었고 PATH 기본 `protoc`는 v3.21.12였다. 둘로 재생성하면 생성 헤더의 protoc 버전 주석만 달라져 무관한 drift가 발생하므로, 환경에 존재하던 protoc 29.3(`/tmp/protoc29/bin`)을 PATH 앞에 두고 재생성했다. 그 결과 생성물 diff는 계획대로 `market.pb.go`/`market.pb.dart`/`market.pbjson.dart`에만 한정됐고 `bin/contracts-check`는 exit 0이다. 명령 자체는 대체하지 않았다. +- `services/api/internal/contracts/parser_map_test.go`는 항목 추가 후 `gofmt -w`가 import 블록 순서를 표준 정렬로 정규화했다(기능 변화 없음). `git diff --check`와 gofmt 모두 clean. +- 계획 [API-2]/[API-4]에서 capability 추가는 "advertise 시"로 선택 사항이었다. operator 명령 표면을 노출하므로 API에는 정적 `market-import` capability를, worker에는 `DailyBarImporter` 의존이 있을 때만 켜지는 `market-import` capability를 추가하고 관련 sync 테스트를 갱신했다. +- worker `cmd/alt-worker/main.go` ready 로그에 `market_import_surface` 불리언 지표를 추가했다(기존 `market_read_surface` 패턴과 동일, secret 미노출). + +## 주요 설계 결정 + +- proto는 additive로만 변경했다. `ImportDailyBarsRequest/Response`를 `ListBarsResponse` 뒤에 새 field 번호로 추가하고 기존 field 번호는 건드리지 않았다(contracts domain rule: additive 우선, 기존 field 번호 불변). +- API는 얇은 control plane을 유지했다. `handleImportDailyBars`는 provider/selector_kind/symbols 필수 검증, market/venue enum 범위 검증, YYYYMMDD 날짜 형식 검증을 worker 연결 전에 수행하고, 실행은 worker로 forward한다. worker 내부 타입을 import하지 않는다. +- worker가 import 실행을 소유한다. socket handler는 좁은 `DailyBarImporter` 인터페이스에만 의존해 provider/storage wiring과 분리되고, 결과 counts를 typed 응답으로 돌려준다. 오류는 handler error가 아니라 `ErrorInfo`로 매핑한다(worker rule: typed contract error 반환). +- venue 변환은 `venueFromProto`를 추가해 처리했다. import selector는 venue를 지정하지 않을 수 있으므로 `VENUE_UNSPECIFIED`는 빈 도메인 venue로 허용하고 범위 밖 enum만 거부한다. market은 기존 `marketFilterFromProto`(unspecified 허용)를 재사용했다. +- `alt-worker` entrypoint는 importer 인스턴스 하나를 만들어 job handler와 socket dep에 함께 주입한다. app key/token/account/raw header/secret item 이름은 로깅하거나 노출하지 않는다. +- import 명령 handler는 항상 등록하되 capability는 의존 존재 여부로 게이트한다(worker는 `DailyBarImporter`가 있을 때만 `market-import` advertise). backtest 핸들러/capability와 동일한 패턴. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Protobuf change is additive and generated files were regenerated rather than hand-edited. +- API remains a thin validator/forwarder and does not import worker internals. +- Worker owns import execution and returns typed `ErrorInfo` instead of handler errors. +- Capability strings match registered handlers and available deps. +- No secret/account/token/raw KIS payload is logged or written. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +> 참고: `bin/contracts-check`/`bin/contracts-gen`는 PATH에 protoc 29.3(committed 생성물과 동일 버전)을 두고 실행했다. 근거는 `계획 대비 변경 사항` 참고. + +### API-1 중간 검증 +```bash +$ bin/contracts-check +[exit 0] +$ go test ./packages/contracts/gen/go/... -count=1 +? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files] +$ go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator -run ParserMap -count=1 +ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.009s +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.006s +``` + +### API-2 중간 검증 +```bash +$ go test ./services/api/... -count=1 +? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files] +ok git.toki-labs.com/toki/alt/services/api/internal/config 0.004s +ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.007s +ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.022s +ok git.toki-labs.com/toki/alt/services/api/internal/workerclient 0.414s +``` + +### API-3 중간 검증 +```bash +$ go test ./services/worker/internal/socket -count=1 +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.063s +``` + +### API-4 중간 검증 +```bash +$ go build ./services/worker/... +BUILD OK +$ go test ./services/worker/... -count=1 +? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker [no test files] +? 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.003s +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/datacheck 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.027s +ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.068s +ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.305s +``` + +### API-5 중간 검증 +```bash +$ git diff --check +[diff-check clean, exit 0] +``` + +### 최종 검증 +```bash +$ bin/contracts-check +[exit 0] +$ go test ./packages/contracts/gen/go/... -count=1 +? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files] +$ go test ./services/api/... -count=1 +? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files] +ok git.toki-labs.com/toki/alt/services/api/internal/config 0.002s +ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.010s +ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.011s +ok git.toki-labs.com/toki/alt/services/api/internal/workerclient 0.416s +$ go test ./services/worker/... -count=1 +? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker [no test files] +? 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.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.004s +ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.026s +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.069s +ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.218s +$ git diff --check +[diff-check clean, exit 0] +``` + +--- + +> **[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. + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Fail + - code quality: Pass + - plan deviation: Fail + - verification trust: Pass +- 발견된 문제: + - Required: [services/api/internal/socket/market.go](/config/workspace/alt/services/api/internal/socket/market.go:77) and [services/worker/internal/socket/market.go](/config/workspace/alt/services/worker/internal/socket/market.go:88) validate `from_yyyymmdd` and `to_yyyymmdd` independently, but never require the live KIS date range or reject `from > to` before forwarding. The live provider requires both dates and only checks ordering after worker/provider handling ([services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:298), [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:223)), so missing or reversed ranges become backend/internal failures instead of typed `invalid_request` responses and can cross the API/worker boundary unnecessarily. Fix by centralizing import date range validation in both API and worker handlers, requiring both fields for the current KIS import surface, rejecting `from > to` as `invalid_request`, and adding API/worker regression tests that prove no worker/importer call occurs for missing or reversed ranges. +- 다음 단계: WARN/FAIL이고 user-review gate가 트리거되지 않았으므로 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/code_review_cloud_G08_1.log b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/code_review_cloud_G08_1.log new file mode 100644 index 0000000..a8afb34 --- /dev/null +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/code_review_cloud_G08_1.log @@ -0,0 +1,211 @@ + + +# Code Review Reference - REVIEW_API + +> **[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. +> 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-03 +task=m-command-first-operator-workflow/01_import_contract_worker_api, plan=1, tag=REVIEW_API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/01_import_contract_worker_api/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] Date Range Validation | [x] | +| [REVIEW_API-2] Verification | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_API-1] Tighten `ImportDailyBars` date range validation in API and worker handlers, with regression tests for missing and reversed ranges. +- [x] [REVIEW_API-2] Run focused and full verification, including generated-contract and client lint checks. +- [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_G08_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/`를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/01_import_contract_worker_api/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-command-first-operator-workflow/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.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로 이동한다. + +## 계획 대비 변경 사항 + +- 계획대로 구현했고 명령 대체는 없다. `bin/contracts-check`는 committed 생성물과 동일한 protoc 29.3(=v5.29.3)을 PATH 앞에 두고 실행했다(기본 PATH의 protoc v3.21.12로 재생성하면 생성 헤더의 protoc 버전 주석만 달라지는 무관한 drift 발생). 본 follow-up은 `.proto`를 건드리지 않으므로 생성물 변경은 없고 `git diff --check`/`bin/contracts-check` 모두 clean이다. +- `bin/lint`의 `flutter analyze`는 `lib/src/integrations/mattermost/mattermost_auth_service.dart`의 `avoid_print` info 13건을 보고하지만 본 변경(생성 Dart contract 및 Go socket validation)과 무관한 기존 항목이며, `--no-fatal-infos`로 exit 0이다. + +## 주요 설계 결정 + +- 첫 리뷰 FAIL 원인: import 명령의 날짜를 optional로 두고 `from <= to`를 검증하지 않아, live KIS importer가 요구하는 bounded/정렬된 date range 위반이 socket boundary의 typed `invalid_request`가 아니라 worker-internal backend 실패로 새어 나갔다. +- API와 worker handler 양쪽에서 worker/importer 호출 **이전에** date range를 검증하도록 좁혔다. 현재 KIS import command 표면에서는 `from_yyyymmdd`/`to_yyyymmdd`를 둘 다 필수로 요구하고, YYYYMMDD 파싱 실패와 `from > to`를 모두 `invalid_request`로 닫는다. +- provider의 기본 날짜 동작은 바꾸지 않았다(범위 결정 근거대로). live KIS provider가 날짜를 요구하므로 socket 명령은 빈 날짜에 default range를 발명하지 않고 거부한다. +- API는 `validateImportDate`(빈 값 허용)를 `validateImportDateRange(from, to)`로 교체했고, worker는 endpoint별 `parseImportDate`를 범위 검증과 정렬 검사를 포함해 파싱된 from/to를 돌려주는 `parseImportDateRange(from, to)`로 교체했다. worker `market.go`에서 더는 쓰이지 않는 `fmt` import를 제거했다. +- 회귀 테스트는 API/worker 모두 valid 요청을 복제해 한 필드만 변형하는 table 패턴으로 재작성하여 missing `from`/missing `to`/malformed/reversed 범위를 각각 격리 검증한다. API 케이스는 검증 실패 시 worker가 연결조차 되지 않음을 `connectCount == 0`과 `importReq == nil`로, worker 케이스는 `fakeDailyBarImporter.called == false`로 단언한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret, 또는 범위 충돌 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Missing `from_yyyymmdd`, missing `to_yyyymmdd`, and `from > to` are rejected as typed `invalid_request`. +- API validation rejects bad date ranges before `worker.Connect` or `ImportDailyBars`. +- Worker validation rejects bad date ranges before `DailyBarImporter.ImportDailyBars`. +- Focused tests and full original contract/API/worker/client lint verification were rerun with actual stdout/stderr. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +> 참고: `bin/contracts-check`는 PATH에 protoc 29.3(committed 생성물과 동일 버전)을 두고 실행했다. 근거는 `계획 대비 변경 사항` 참고. + +### REVIEW_API-1 중간 검증 +```bash +$ go test ./services/api/internal/socket -run TestHandleImportDailyBars -count=1 +ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.007s +# 신규/회귀 케이스(-v): missing_from_date, missing_to_date, malformed_from_date, +# malformed_to_date, reversed_range 모두 PASS (invalid_request 반환, connectCount==0) +$ go test ./services/worker/internal/socket -run TestHandleImportDailyBars -count=1 +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.005s +# 신규/회귀 케이스(-v): missing_from_date, missing_to_date, malformed_from_date, +# malformed_to_date, reversed_range 모두 PASS (invalid_request 반환, importer 미호출) +``` + +### REVIEW_API-2 중간 검증 +```bash +$ bin/contracts-check +[exit 0] +$ go test ./packages/contracts/gen/go/... -count=1 +? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files] +$ go test ./services/api/... -count=1 +? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files] +ok git.toki-labs.com/toki/alt/services/api/internal/config 0.009s +ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.008s +ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.011s +ok git.toki-labs.com/toki/alt/services/api/internal/workerclient 0.414s +$ go test ./services/worker/... -count=1 +? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker [no test files] +? 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.008s +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.004s +ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.008s +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.004s +ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.036s +ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.065s +ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.206s +$ bin/lint +Analyzing client... + info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:27:7 • avoid_print + ... (mattermost avoid_print info 13건, 본 변경과 무관) +13 issues found. (ran in 12.7s) +[go vet 5개 모듈 무출력; flutter analyze --no-fatal-infos exit 0; bin/lint exit 0] +$ git diff --check +[diff-check clean, exit 0] +``` + +### 최종 검증 +```bash +$ go test ./services/api/internal/socket -run TestHandleImportDailyBars -count=1 +ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.007s +$ go test ./services/worker/internal/socket -run TestHandleImportDailyBars -count=1 +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.005s +$ bin/contracts-check +[exit 0] +$ go test ./packages/contracts/gen/go/... -count=1 +? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files] +$ go test ./services/api/... -count=1 +? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files] +ok git.toki-labs.com/toki/alt/services/api/internal/config 0.009s +ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.008s +ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.011s +ok git.toki-labs.com/toki/alt/services/api/internal/workerclient 0.414s +$ go test ./services/worker/... -count=1 +? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker [no test files] +? 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.008s +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.004s +ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.008s +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.006s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.004s +ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.036s +ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.065s +ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.206s +$ bin/lint +[go vet 무출력; flutter analyze mattermost avoid_print info 13건만 보고; exit 0] +$ git diff --check +[diff-check clean, exit 0] +``` + +--- + +> **[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. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/complete.log b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/complete.log new file mode 100644 index 0000000..bcee3d4 --- /dev/null +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/complete.log @@ -0,0 +1,44 @@ +# Complete - m-command-first-operator-workflow/01_import_contract_worker_api + +## 완료 일시 + +2026-06-03 + +## 요약 + +Import daily bars contract/API/worker rail completed after 2 review loops; final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | Import command date range validation was incomplete; follow-up required API/worker invalid_request handling before worker/importer calls. | +| `plan_cloud_G08_1.log` | `code_review_cloud_G08_1.log` | PASS | Missing, malformed, and reversed import date ranges are rejected at API/worker boundaries with regression coverage. | + +## 구현/정리 내용 + +- Added additive `ImportDailyBarsRequest` and `ImportDailyBarsResponse` contracts with regenerated Go/Dart outputs. +- Registered import request/response parsers in API, worker, and CLI parser maps. +- Added API workerclient forwarding and API socket validation/forwarding for import daily bars. +- Added worker socket import handler, request conversion, typed error mapping, and `alt-worker` importer dependency wiring. +- Added `market-import` capability coverage for API and worker surfaces. +- Closed the review follow-up by requiring `from_yyyymmdd`/`to_yyyymmdd`, rejecting malformed and reversed ranges before worker/importer calls, and adding regression tests. + +## 최종 검증 + +- `go test ./services/api/internal/socket -run TestHandleImportDailyBars -count=1` - PASS; `ok git.toki-labs.com/toki/alt/services/api/internal/socket`. +- `go test ./services/worker/internal/socket -run TestHandleImportDailyBars -count=1` - PASS; `ok git.toki-labs.com/toki/alt/services/worker/internal/socket`. +- `bin/contracts-check` - PASS; exit 0 with no generated drift output. +- `go test ./packages/contracts/gen/go/... -count=1` - PASS; package has no test files. +- `go test ./services/api/... -count=1` - PASS; all API packages passed. +- `go test ./services/worker/... -count=1` - PASS; all worker packages passed. +- `bin/lint` - PASS; exit 0. Flutter analyze still reports 13 pre-existing `avoid_print` info entries in `lib/src/integrations/mattermost/mattermost_auth_service.dart`, unrelated to this change. +- `git diff --check` - PASS; no whitespace warnings. + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/PLAN-cloud-G08.md b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/plan_cloud_G08_0.log similarity index 100% rename from agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/PLAN-cloud-G08.md rename to agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/plan_cloud_G08_0.log diff --git a/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/plan_cloud_G08_1.log b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/plan_cloud_G08_1.log new file mode 100644 index 0000000..fba892e --- /dev/null +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/01_import_contract_worker_api/plan_cloud_G08_1.log @@ -0,0 +1,220 @@ + + +# Plan - Review Follow-Up Import Date Range Validation + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 코드리뷰 FAIL 후속 구현 지시서다. 구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 반드시 실제 내용과 검증 출력으로 채운다. 검증은 아래 명령을 실행하고 실제 stdout/stderr를 review stub에 붙인다. 최종 판정, archive 이동, `complete.log` 작성은 code-review-skill 전용이다. 사용자 결정, 사용자 소유 외부 환경/secret, 또는 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +첫 구현은 import command contract/API/worker rail을 추가했지만, date range를 API/worker handler에서 의미적으로 막지 못했다. 현재 live KIS importer는 `from/to` date range를 요구하고 `from <= to`를 기대하므로, operator command의 잘못된 날짜 입력은 API/worker boundary에서 typed `invalid_request`로 닫혀야 한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 사용자-only blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 이 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 기준이며, code-review가 검증 후 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-roadmap/current.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-test/local/rules.md` +- `agent-test/local/contracts-smoke.md` +- `agent-test/local/api-smoke.md` +- `agent-test/local/worker-smoke.md` +- `agent-test/local/client-smoke.md` +- `agent-test/local/operations-smoke.md` +- `agent-ops/rules/project/domain/contracts/rules.md` +- `agent-ops/rules/project/domain/api/rules.md` +- `agent-ops/rules/project/domain/worker/rules.md` +- `agent-ops/rules/project/domain/client/rules.md` +- `agent-ops/rules/project/domain/operations/rules.md` +- `agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/plan_cloud_G08_0.log` +- `agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/code_review_cloud_G08_0.log` +- `packages/contracts/proto/alt/v1/market.proto` +- `packages/contracts/gen/go/alt/v1/market.pb.go` +- `apps/client/lib/src/generated/alt/v1/market.pb.dart` +- `apps/client/lib/src/generated/alt/v1/market.pbjson.dart` +- `services/api/internal/socket/market.go` +- `services/api/internal/socket/market_test.go` +- `services/api/internal/socket/backtest_test.go` +- `services/api/internal/socket/server_test.go` +- `services/api/internal/workerclient/client.go` +- `services/api/internal/workerclient/client_test.go` +- `services/worker/internal/socket/backtest.go` +- `services/worker/internal/socket/handlers.go` +- `services/worker/internal/socket/market.go` +- `services/worker/internal/socket/market_mapping.go` +- `services/worker/internal/socket/market_test.go` +- `services/worker/internal/socket/server_test.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/providers/kis/live_client.go` +- `services/worker/internal/providers/kis/daily_itemchartprice_test.go` +- `services/worker/cmd/alt-worker/main.go` +- `packages/domain/market/types.go` + +### 테스트 환경 규칙 + +- test_env: `local`. +- `agent-test/local/rules.md` present/read. +- Matched profiles read: `contracts-smoke`, `api-smoke`, `worker-smoke`, `client-smoke`, `operations-smoke`. +- Applied commands: focused `go test` for API/worker socket regressions, full `bin/contracts-check`, `go test ./packages/contracts/gen/go/... -count=1`, `go test ./services/api/... -count=1`, `go test ./services/worker/... -count=1`, `bin/lint`, `git diff --check`. +- Fallback: none. No `<확인 필요>` command values in matched profiles. + +### 테스트 커버리지 공백 + +- API import validation tests cover malformed date strings, but not missing `from_yyyymmdd`, missing `to_yyyymmdd`, or `from > to`. +- Worker import validation tests cover malformed date strings, but not missing date endpoints or reversed date ranges. +- Existing KIS provider tests prove the live provider requires date range and validates ordering, but socket handlers currently let those user-input errors reach backend handling. + +### 심볼 참조 + +- Renamed/removed symbols: none. +- New or touched symbols to audit after the fix: `handleImportDailyBars`, `validateImportDate`, `parseImportDate`, `validImportRequest`, `fakeWorkerClient.connectCount`, `fakeDailyBarImporter.called`. + +### 분할 판단 + +- Split policy evaluated. This is a follow-up inside existing split subtask `01_import_contract_worker_api`. +- No new subtask is created because the fix is one narrow validation/test unit across API and worker handlers, and splitting would separate mirrored boundary tests from the same bug. + +### 범위 결정 근거 + +- Include API/worker import date range validation and regression tests only. +- Exclude protobuf schema changes, CLI YAML action implementation, generated contract regeneration, Flutter UI, KIS live smoke, and roadmap updates. +- Keep provider default-date behavior unchanged. Current live KIS provider requires `from/to`, so the socket command should reject missing dates instead of inventing a default range. + +### 빌드 등급 + +- build=`cloud-G08`, review=`cloud-G08`: follow-up is narrow but still closes a cross-domain protocol/API/worker validation failure from a cloud-routed contract task. + +## 구현 체크리스트 + +- [ ] [REVIEW_API-1] Tighten `ImportDailyBars` date range validation in API and worker handlers, with regression tests for missing and reversed ranges. +- [ ] [REVIEW_API-2] Run focused and full verification, including generated-contract and client lint checks. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [REVIEW_API-1] Date Range Validation + +### 문제 + +[services/api/internal/socket/market.go](/config/workspace/alt/services/api/internal/socket/market.go:77) validates `from_yyyymmdd` and `to_yyyymmdd` independently, then opens the worker connection. [services/worker/internal/socket/market.go](/config/workspace/alt/services/worker/internal/socket/market.go:88) does the same before calling the importer. The live KIS provider requires both dates and validates `from <= to` only after worker/provider handling ([services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:298), [services/worker/internal/providers/kis/live_client.go](/config/workspace/alt/services/worker/internal/providers/kis/live_client.go:223)), so bad user input becomes `internal` backend failure instead of typed `invalid_request`. + +### 해결 방법 + +Add date range validation in both socket boundaries before worker/importer calls: + +- Require `from_yyyymmdd` and `to_yyyymmdd` to be present for the current KIS import command surface. +- Parse both as YYYYMMDD. +- Reject `from > to` with `invalid_request`. +- In API tests, assert the fake worker is not connected or called for missing/reversed ranges. +- In worker tests, assert the fake importer is not called for missing/reversed ranges. + +Before ([services/api/internal/socket/market.go](/config/workspace/alt/services/api/internal/socket/market.go:77)): + +```go +if err := validateImportDate("from_yyyymmdd", req.GetFromYyyymmdd()); err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil +} +if err := validateImportDate("to_yyyymmdd", req.GetToYyyymmdd()); err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil +} +``` + +After: + +```go +if err := validateImportDateRange(req.GetFromYyyymmdd(), req.GetToYyyymmdd()); err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil +} +``` + +Use equivalent worker-side parsing that returns parsed `from` and `to` for the importer request. + +### 수정 파일 및 체크리스트 + +- [ ] `services/api/internal/socket/market.go`: replace independent optional date validation with required range validation. +- [ ] `services/api/internal/socket/market_test.go`: add missing `from_yyyymmdd`, missing `to_yyyymmdd`, and reversed range validation cases; assert `connectCount == 0` and `importReq == nil`. +- [ ] `services/worker/internal/socket/market.go`: replace independent optional date parsing with required range parsing and `from <= to` validation. +- [ ] `services/worker/internal/socket/market_test.go`: add missing date and reversed range cases; assert fake importer is not called. + +### 테스트 작성 + +- Add API regression coverage to `TestHandleImportDailyBarsValidation`. +- Add worker regression coverage to `TestHandleImportDailyBarsValidation`. +- No provider test changes are needed; KIS provider already covers malformed date/order behavior. + +### 중간 검증 + +```bash +go test ./services/api/internal/socket -run TestHandleImportDailyBars -count=1 +go test ./services/worker/internal/socket -run TestHandleImportDailyBars -count=1 +``` + +Expected: both commands exit 0 and the new invalid range cases return `invalid_request`. + +## [REVIEW_API-2] Verification + +### 문제 + +The original task spans generated contracts, API, worker, and generated Dart client output. The follow-up must prove the validation fix does not leave contract drift or client generated analyzer fallout. + +### 해결 방법 + +Run focused tests first, then rerun the full final command set. Use fresh Go tests with `-count=1`; cached output is not acceptable. + +### 수정 파일 및 체크리스트 + +- [ ] No additional source files. + +### 테스트 작성 + +No new test files beyond `REVIEW_API-1`. + +### 중간 검증 + +```bash +bin/contracts-check +go test ./packages/contracts/gen/go/... -count=1 +go test ./services/api/... -count=1 +go test ./services/worker/... -count=1 +bin/lint +git diff --check +``` + +Expected: all commands exit 0. If `bin/lint` reports pre-existing analyzer info but exits 0, paste the actual output and note whether it is unrelated to this generated contract change. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/api/internal/socket/market.go` | REVIEW_API-1 | +| `services/api/internal/socket/market_test.go` | REVIEW_API-1 | +| `services/worker/internal/socket/market.go` | REVIEW_API-1 | +| `services/worker/internal/socket/market_test.go` | REVIEW_API-1 | + +## 최종 검증 + +```bash +go test ./services/api/internal/socket -run TestHandleImportDailyBars -count=1 +go test ./services/worker/internal/socket -run TestHandleImportDailyBars -count=1 +bin/contracts-check +go test ./packages/contracts/gen/go/... -count=1 +go test ./services/api/... -count=1 +go test ./services/worker/... -count=1 +bin/lint +git diff --check +``` + +Expected: all commands exit 0. Fresh `go test -count=1` is required; cached output is not acceptable. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/CODE_REVIEW-cloud-G08.md b/agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/CODE_REVIEW-cloud-G08.md deleted file mode 100644 index 2081554..0000000 --- a/agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/CODE_REVIEW-cloud-G08.md +++ /dev/null @@ -1,155 +0,0 @@ - - -# Code Review Reference - API - -> **[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. -> 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-03 -task=m-command-first-operator-workflow/01_import_contract_worker_api, plan=0, tag=API - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/01_import_contract_worker_api/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. -4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. -5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Contract And Parser Surface | [ ] | -| [API-2] API Forwarding | [ ] | -| [API-3] Worker Import Handler | [ ] | -| [API-4] Worker Entrypoint Wiring | [ ] | -| [API-5] Focused Verification | [ ] | - -## 구현 체크리스트 - -- [ ] [API-1] Add `ImportDailyBarsRequest/Response` protobuf messages, regenerate contracts, and register parsers in API/worker/CLI maps. -- [ ] [API-2] Add API workerclient and API socket forwarding for import daily bars with typed unavailable/timeout/internal errors. -- [ ] [API-3] Add worker socket import handler that validates request shape, calls the importer dependency, and returns instrument/bar counts. -- [ ] [API-4] Wire `alt-worker` store-backed deps with the KIS importer command surface without exposing secrets. -- [ ] [API-5] Run focused contract/API/worker verification and `git diff --check`. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-command-first-operator-workflow/01_import_contract_worker_api/`를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/01_import_contract_worker_api/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-command-first-operator-workflow/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.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로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- Protobuf change is additive and generated files were regenerated rather than hand-edited. -- API remains a thin validator/forwarder and does not import worker internals. -- Worker owns import execution and returns typed `ErrorInfo` instead of handler errors. -- Capability strings match registered handlers and available deps. -- No secret/account/token/raw KIS payload is logged or written. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. -- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. - -### API-1 중간 검증 -```bash -$ bin/contracts-check -$ go test ./packages/contracts/gen/go/... -count=1 -$ go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator -run ParserMap -count=1 -(output) -``` - -### API-2 중간 검증 -```bash -$ go test ./services/api/... -count=1 -(output) -``` - -### API-3 중간 검증 -```bash -$ go test ./services/worker/internal/socket -count=1 -(output) -``` - -### API-4 중간 검증 -```bash -$ go test ./services/worker/... -count=1 -(output) -``` - -### API-5 중간 검증 -```bash -$ git diff --check -(output) -``` - -### 최종 검증 -```bash -$ bin/contracts-check -$ go test ./packages/contracts/gen/go/... -count=1 -$ go test ./services/api/... -count=1 -$ go test ./services/worker/... -count=1 -$ git diff --check -(output) -``` - ---- - -> **[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. diff --git a/apps/cli/internal/operator/parser_map.go b/apps/cli/internal/operator/parser_map.go index 64d6b04..b623465 100644 --- a/apps/cli/internal/operator/parser_map.go +++ b/apps/cli/internal/operator/parser_map.go @@ -22,6 +22,9 @@ func messageFactories() []func() proto.Message { func() proto.Message { return &altv1.ListInstrumentsResponse{} }, func() proto.Message { return &altv1.ListBarsRequest{} }, func() proto.Message { return &altv1.ListBarsResponse{} }, + // market import command: daily bars + func() proto.Message { return &altv1.ImportDailyBarsRequest{} }, + func() proto.Message { return &altv1.ImportDailyBarsResponse{} }, // backtest surface: start / list / detail / result / compare func() proto.Message { return &altv1.StartBacktestRequest{} }, func() proto.Message { return &altv1.StartBacktestResponse{} }, diff --git a/apps/cli/internal/operator/parser_map_test.go b/apps/cli/internal/operator/parser_map_test.go index 63de62c..1126af8 100644 --- a/apps/cli/internal/operator/parser_map_test.go +++ b/apps/cli/internal/operator/parser_map_test.go @@ -19,6 +19,9 @@ func TestParserMapIncludesRunnerMessages(t *testing.T) { &altv1.ListInstrumentsResponse{}, &altv1.ListBarsRequest{}, &altv1.ListBarsResponse{}, + // market import command surface a later CLI subtask sends/receives + &altv1.ImportDailyBarsRequest{}, + &altv1.ImportDailyBarsResponse{}, // backtest surface is parsed too so the CLI rail matches the API/worker // parser maps and a later subtask can reuse it without drift. &altv1.StartBacktestRequest{}, diff --git a/apps/client/lib/src/generated/alt/v1/market.pb.dart b/apps/client/lib/src/generated/alt/v1/market.pb.dart index c85e946..c32ed3a 100644 --- a/apps/client/lib/src/generated/alt/v1/market.pb.dart +++ b/apps/client/lib/src/generated/alt/v1/market.pb.dart @@ -707,6 +707,235 @@ class ListBarsResponse extends $pb.GeneratedMessage { $0.ErrorInfo ensureError() => $_ensure(1); } +class ImportDailyBarsRequest extends $pb.GeneratedMessage { + factory ImportDailyBarsRequest({ + $core.String? provider, + $core.String? selectorKind, + $0.Market? market, + $0.Venue? venue, + $core.String? name, + $core.Iterable<$core.String>? symbols, + $core.String? fromYyyymmdd, + $core.String? toYyyymmdd, + }) { + final result = create(); + if (provider != null) result.provider = provider; + if (selectorKind != null) result.selectorKind = selectorKind; + if (market != null) result.market = market; + if (venue != null) result.venue = venue; + if (name != null) result.name = name; + if (symbols != null) result.symbols.addAll(symbols); + if (fromYyyymmdd != null) result.fromYyyymmdd = fromYyyymmdd; + if (toYyyymmdd != null) result.toYyyymmdd = toYyyymmdd; + return result; + } + + ImportDailyBarsRequest._(); + + factory ImportDailyBarsRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ImportDailyBarsRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ImportDailyBarsRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'provider') + ..aOS(2, _omitFieldNames ? '' : 'selectorKind') + ..aE<$0.Market>(3, _omitFieldNames ? '' : 'market', + enumValues: $0.Market.values) + ..aE<$0.Venue>(4, _omitFieldNames ? '' : 'venue', + enumValues: $0.Venue.values) + ..aOS(5, _omitFieldNames ? '' : 'name') + ..pPS(6, _omitFieldNames ? '' : 'symbols') + ..aOS(7, _omitFieldNames ? '' : 'fromYyyymmdd') + ..aOS(8, _omitFieldNames ? '' : 'toYyyymmdd') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImportDailyBarsRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImportDailyBarsRequest copyWith( + void Function(ImportDailyBarsRequest) updates) => + super.copyWith((message) => updates(message as ImportDailyBarsRequest)) + as ImportDailyBarsRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ImportDailyBarsRequest create() => ImportDailyBarsRequest._(); + @$core.override + ImportDailyBarsRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static ImportDailyBarsRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ImportDailyBarsRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get provider => $_getSZ(0); + @$pb.TagNumber(1) + set provider($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasProvider() => $_has(0); + @$pb.TagNumber(1) + void clearProvider() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get selectorKind => $_getSZ(1); + @$pb.TagNumber(2) + set selectorKind($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasSelectorKind() => $_has(1); + @$pb.TagNumber(2) + void clearSelectorKind() => $_clearField(2); + + @$pb.TagNumber(3) + $0.Market get market => $_getN(2); + @$pb.TagNumber(3) + set market($0.Market value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasMarket() => $_has(2); + @$pb.TagNumber(3) + void clearMarket() => $_clearField(3); + + @$pb.TagNumber(4) + $0.Venue get venue => $_getN(3); + @$pb.TagNumber(4) + set venue($0.Venue value) => $_setField(4, value); + @$pb.TagNumber(4) + $core.bool hasVenue() => $_has(3); + @$pb.TagNumber(4) + void clearVenue() => $_clearField(4); + + @$pb.TagNumber(5) + $core.String get name => $_getSZ(4); + @$pb.TagNumber(5) + set name($core.String value) => $_setString(4, value); + @$pb.TagNumber(5) + $core.bool hasName() => $_has(4); + @$pb.TagNumber(5) + void clearName() => $_clearField(5); + + @$pb.TagNumber(6) + $pb.PbList<$core.String> get symbols => $_getList(5); + + @$pb.TagNumber(7) + $core.String get fromYyyymmdd => $_getSZ(6); + @$pb.TagNumber(7) + set fromYyyymmdd($core.String value) => $_setString(6, value); + @$pb.TagNumber(7) + $core.bool hasFromYyyymmdd() => $_has(6); + @$pb.TagNumber(7) + void clearFromYyyymmdd() => $_clearField(7); + + @$pb.TagNumber(8) + $core.String get toYyyymmdd => $_getSZ(7); + @$pb.TagNumber(8) + set toYyyymmdd($core.String value) => $_setString(7, value); + @$pb.TagNumber(8) + $core.bool hasToYyyymmdd() => $_has(7); + @$pb.TagNumber(8) + void clearToYyyymmdd() => $_clearField(8); +} + +class ImportDailyBarsResponse extends $pb.GeneratedMessage { + factory ImportDailyBarsResponse({ + $core.String? provider, + $core.int? instrumentCount, + $core.int? barCount, + $0.ErrorInfo? error, + }) { + final result = create(); + if (provider != null) result.provider = provider; + if (instrumentCount != null) result.instrumentCount = instrumentCount; + if (barCount != null) result.barCount = barCount; + if (error != null) result.error = error; + return result; + } + + ImportDailyBarsResponse._(); + + factory ImportDailyBarsResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ImportDailyBarsResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ImportDailyBarsResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'provider') + ..aI(2, _omitFieldNames ? '' : 'instrumentCount') + ..aI(3, _omitFieldNames ? '' : 'barCount') + ..aOM<$0.ErrorInfo>(4, _omitFieldNames ? '' : 'error', + subBuilder: $0.ErrorInfo.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImportDailyBarsResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ImportDailyBarsResponse copyWith( + void Function(ImportDailyBarsResponse) updates) => + super.copyWith((message) => updates(message as ImportDailyBarsResponse)) + as ImportDailyBarsResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ImportDailyBarsResponse create() => ImportDailyBarsResponse._(); + @$core.override + ImportDailyBarsResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static ImportDailyBarsResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ImportDailyBarsResponse? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get provider => $_getSZ(0); + @$pb.TagNumber(1) + set provider($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasProvider() => $_has(0); + @$pb.TagNumber(1) + void clearProvider() => $_clearField(1); + + @$pb.TagNumber(2) + $core.int get instrumentCount => $_getIZ(1); + @$pb.TagNumber(2) + set instrumentCount($core.int value) => $_setSignedInt32(1, value); + @$pb.TagNumber(2) + $core.bool hasInstrumentCount() => $_has(1); + @$pb.TagNumber(2) + void clearInstrumentCount() => $_clearField(2); + + @$pb.TagNumber(3) + $core.int get barCount => $_getIZ(2); + @$pb.TagNumber(3) + set barCount($core.int value) => $_setSignedInt32(2, value); + @$pb.TagNumber(3) + $core.bool hasBarCount() => $_has(2); + @$pb.TagNumber(3) + void clearBarCount() => $_clearField(3); + + @$pb.TagNumber(4) + $0.ErrorInfo get error => $_getN(3); + @$pb.TagNumber(4) + set error($0.ErrorInfo value) => $_setField(4, value); + @$pb.TagNumber(4) + $core.bool hasError() => $_has(3); + @$pb.TagNumber(4) + void clearError() => $_clearField(4); + @$pb.TagNumber(4) + $0.ErrorInfo ensureError() => $_ensure(3); +} + const $core.bool _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); const $core.bool _omitMessageNames = diff --git a/apps/client/lib/src/generated/alt/v1/market.pbjson.dart b/apps/client/lib/src/generated/alt/v1/market.pbjson.dart index 6d7848b..5e023ea 100644 --- a/apps/client/lib/src/generated/alt/v1/market.pbjson.dart +++ b/apps/client/lib/src/generated/alt/v1/market.pbjson.dart @@ -266,3 +266,66 @@ const ListBarsResponse$json = { final $typed_data.Uint8List listBarsResponseDescriptor = $convert.base64Decode( 'ChBMaXN0QmFyc1Jlc3BvbnNlEh8KBGJhcnMYASADKAsyCy5hbHQudjEuQmFyUgRiYXJzEicKBW' 'Vycm9yGAIgASgLMhEuYWx0LnYxLkVycm9ySW5mb1IFZXJyb3I='); + +@$core.Deprecated('Use importDailyBarsRequestDescriptor instead') +const ImportDailyBarsRequest$json = { + '1': 'ImportDailyBarsRequest', + '2': [ + {'1': 'provider', '3': 1, '4': 1, '5': 9, '10': 'provider'}, + {'1': 'selector_kind', '3': 2, '4': 1, '5': 9, '10': 'selectorKind'}, + { + '1': 'market', + '3': 3, + '4': 1, + '5': 14, + '6': '.alt.v1.Market', + '10': 'market' + }, + { + '1': 'venue', + '3': 4, + '4': 1, + '5': 14, + '6': '.alt.v1.Venue', + '10': 'venue' + }, + {'1': 'name', '3': 5, '4': 1, '5': 9, '10': 'name'}, + {'1': 'symbols', '3': 6, '4': 3, '5': 9, '10': 'symbols'}, + {'1': 'from_yyyymmdd', '3': 7, '4': 1, '5': 9, '10': 'fromYyyymmdd'}, + {'1': 'to_yyyymmdd', '3': 8, '4': 1, '5': 9, '10': 'toYyyymmdd'}, + ], +}; + +/// Descriptor for `ImportDailyBarsRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List importDailyBarsRequestDescriptor = $convert.base64Decode( + 'ChZJbXBvcnREYWlseUJhcnNSZXF1ZXN0EhoKCHByb3ZpZGVyGAEgASgJUghwcm92aWRlchIjCg' + '1zZWxlY3Rvcl9raW5kGAIgASgJUgxzZWxlY3RvcktpbmQSJgoGbWFya2V0GAMgASgOMg4uYWx0' + 'LnYxLk1hcmtldFIGbWFya2V0EiMKBXZlbnVlGAQgASgOMg0uYWx0LnYxLlZlbnVlUgV2ZW51ZR' + 'ISCgRuYW1lGAUgASgJUgRuYW1lEhgKB3N5bWJvbHMYBiADKAlSB3N5bWJvbHMSIwoNZnJvbV95' + 'eXl5bW1kZBgHIAEoCVIMZnJvbVl5eXltbWRkEh8KC3RvX3l5eXltbWRkGAggASgJUgp0b1l5eX' + 'ltbWRk'); + +@$core.Deprecated('Use importDailyBarsResponseDescriptor instead') +const ImportDailyBarsResponse$json = { + '1': 'ImportDailyBarsResponse', + '2': [ + {'1': 'provider', '3': 1, '4': 1, '5': 9, '10': 'provider'}, + {'1': 'instrument_count', '3': 2, '4': 1, '5': 5, '10': 'instrumentCount'}, + {'1': 'bar_count', '3': 3, '4': 1, '5': 5, '10': 'barCount'}, + { + '1': 'error', + '3': 4, + '4': 1, + '5': 11, + '6': '.alt.v1.ErrorInfo', + '10': 'error' + }, + ], +}; + +/// Descriptor for `ImportDailyBarsResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List importDailyBarsResponseDescriptor = $convert.base64Decode( + 'ChdJbXBvcnREYWlseUJhcnNSZXNwb25zZRIaCghwcm92aWRlchgBIAEoCVIIcHJvdmlkZXISKQ' + 'oQaW5zdHJ1bWVudF9jb3VudBgCIAEoBVIPaW5zdHJ1bWVudENvdW50EhsKCWJhcl9jb3VudBgD' + 'IAEoBVIIYmFyQ291bnQSJwoFZXJyb3IYBCABKAsyES5hbHQudjEuRXJyb3JJbmZvUgVlcnJvcg' + '=='); diff --git a/packages/contracts/gen/go/alt/v1/market.pb.go b/packages/contracts/gen/go/alt/v1/market.pb.go index 7e7fbbc..727a8de 100644 --- a/packages/contracts/gen/go/alt/v1/market.pb.go +++ b/packages/contracts/gen/go/alt/v1/market.pb.go @@ -533,6 +533,174 @@ func (x *ListBarsResponse) GetError() *ErrorInfo { return nil } +type ImportDailyBarsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + SelectorKind string `protobuf:"bytes,2,opt,name=selector_kind,json=selectorKind,proto3" json:"selector_kind,omitempty"` + Market Market `protobuf:"varint,3,opt,name=market,proto3,enum=alt.v1.Market" json:"market,omitempty"` + Venue Venue `protobuf:"varint,4,opt,name=venue,proto3,enum=alt.v1.Venue" json:"venue,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + Symbols []string `protobuf:"bytes,6,rep,name=symbols,proto3" json:"symbols,omitempty"` + FromYyyymmdd string `protobuf:"bytes,7,opt,name=from_yyyymmdd,json=fromYyyymmdd,proto3" json:"from_yyyymmdd,omitempty"` + ToYyyymmdd string `protobuf:"bytes,8,opt,name=to_yyyymmdd,json=toYyyymmdd,proto3" json:"to_yyyymmdd,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ImportDailyBarsRequest) Reset() { + *x = ImportDailyBarsRequest{} + mi := &file_alt_v1_market_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImportDailyBarsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportDailyBarsRequest) ProtoMessage() {} + +func (x *ImportDailyBarsRequest) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_market_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ImportDailyBarsRequest.ProtoReflect.Descriptor instead. +func (*ImportDailyBarsRequest) Descriptor() ([]byte, []int) { + return file_alt_v1_market_proto_rawDescGZIP(), []int{8} +} + +func (x *ImportDailyBarsRequest) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ImportDailyBarsRequest) GetSelectorKind() string { + if x != nil { + return x.SelectorKind + } + return "" +} + +func (x *ImportDailyBarsRequest) GetMarket() Market { + if x != nil { + return x.Market + } + return Market_MARKET_UNSPECIFIED +} + +func (x *ImportDailyBarsRequest) GetVenue() Venue { + if x != nil { + return x.Venue + } + return Venue_VENUE_UNSPECIFIED +} + +func (x *ImportDailyBarsRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ImportDailyBarsRequest) GetSymbols() []string { + if x != nil { + return x.Symbols + } + return nil +} + +func (x *ImportDailyBarsRequest) GetFromYyyymmdd() string { + if x != nil { + return x.FromYyyymmdd + } + return "" +} + +func (x *ImportDailyBarsRequest) GetToYyyymmdd() string { + if x != nil { + return x.ToYyyymmdd + } + return "" +} + +type ImportDailyBarsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + InstrumentCount int32 `protobuf:"varint,2,opt,name=instrument_count,json=instrumentCount,proto3" json:"instrument_count,omitempty"` + BarCount int32 `protobuf:"varint,3,opt,name=bar_count,json=barCount,proto3" json:"bar_count,omitempty"` + Error *ErrorInfo `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ImportDailyBarsResponse) Reset() { + *x = ImportDailyBarsResponse{} + mi := &file_alt_v1_market_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ImportDailyBarsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImportDailyBarsResponse) ProtoMessage() {} + +func (x *ImportDailyBarsResponse) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_market_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ImportDailyBarsResponse.ProtoReflect.Descriptor instead. +func (*ImportDailyBarsResponse) Descriptor() ([]byte, []int) { + return file_alt_v1_market_proto_rawDescGZIP(), []int{9} +} + +func (x *ImportDailyBarsResponse) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *ImportDailyBarsResponse) GetInstrumentCount() int32 { + if x != nil { + return x.InstrumentCount + } + return 0 +} + +func (x *ImportDailyBarsResponse) GetBarCount() int32 { + if x != nil { + return x.BarCount + } + return 0 +} + +func (x *ImportDailyBarsResponse) GetError() *ErrorInfo { + if x != nil { + return x.Error + } + return nil +} + var File_alt_v1_market_proto protoreflect.FileDescriptor const file_alt_v1_market_proto_rawDesc = "" + @@ -579,7 +747,22 @@ const file_alt_v1_market_proto_rawDesc = "" + "to_unix_ms\x18\x04 \x01(\x03R\btoUnixMs\"\\\n" + "\x10ListBarsResponse\x12\x1f\n" + "\x04bars\x18\x01 \x03(\v2\v.alt.v1.BarR\x04bars\x12'\n" + - "\x05error\x18\x02 \x01(\v2\x11.alt.v1.ErrorInfoR\x05errorBCZAgit.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1;altv1b\x06proto3" + "\x05error\x18\x02 \x01(\v2\x11.alt.v1.ErrorInfoR\x05error\"\x9a\x02\n" + + "\x16ImportDailyBarsRequest\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12#\n" + + "\rselector_kind\x18\x02 \x01(\tR\fselectorKind\x12&\n" + + "\x06market\x18\x03 \x01(\x0e2\x0e.alt.v1.MarketR\x06market\x12#\n" + + "\x05venue\x18\x04 \x01(\x0e2\r.alt.v1.VenueR\x05venue\x12\x12\n" + + "\x04name\x18\x05 \x01(\tR\x04name\x12\x18\n" + + "\asymbols\x18\x06 \x03(\tR\asymbols\x12#\n" + + "\rfrom_yyyymmdd\x18\a \x01(\tR\ffromYyyymmdd\x12\x1f\n" + + "\vto_yyyymmdd\x18\b \x01(\tR\n" + + "toYyyymmdd\"\xa6\x01\n" + + "\x17ImportDailyBarsResponse\x12\x1a\n" + + "\bprovider\x18\x01 \x01(\tR\bprovider\x12)\n" + + "\x10instrument_count\x18\x02 \x01(\x05R\x0finstrumentCount\x12\x1b\n" + + "\tbar_count\x18\x03 \x01(\x05R\bbarCount\x12'\n" + + "\x05error\x18\x04 \x01(\v2\x11.alt.v1.ErrorInfoR\x05errorBCZAgit.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1;altv1b\x06proto3" var ( file_alt_v1_market_proto_rawDescOnce sync.Once @@ -593,7 +776,7 @@ func file_alt_v1_market_proto_rawDescGZIP() []byte { return file_alt_v1_market_proto_rawDescData } -var file_alt_v1_market_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_alt_v1_market_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_alt_v1_market_proto_goTypes = []any{ (*Instrument)(nil), // 0: alt.v1.Instrument (*Price)(nil), // 1: alt.v1.Price @@ -603,39 +786,44 @@ var file_alt_v1_market_proto_goTypes = []any{ (*ListInstrumentsResponse)(nil), // 5: alt.v1.ListInstrumentsResponse (*ListBarsRequest)(nil), // 6: alt.v1.ListBarsRequest (*ListBarsResponse)(nil), // 7: alt.v1.ListBarsResponse - nil, // 8: alt.v1.Instrument.ProviderSymbolsEntry - (Market)(0), // 9: alt.v1.Market - (Venue)(0), // 10: alt.v1.Venue - (Currency)(0), // 11: alt.v1.Currency - (*Decimal)(nil), // 12: alt.v1.Decimal - (Timeframe)(0), // 13: alt.v1.Timeframe - (*ErrorInfo)(nil), // 14: alt.v1.ErrorInfo + (*ImportDailyBarsRequest)(nil), // 8: alt.v1.ImportDailyBarsRequest + (*ImportDailyBarsResponse)(nil), // 9: alt.v1.ImportDailyBarsResponse + nil, // 10: alt.v1.Instrument.ProviderSymbolsEntry + (Market)(0), // 11: alt.v1.Market + (Venue)(0), // 12: alt.v1.Venue + (Currency)(0), // 13: alt.v1.Currency + (*Decimal)(nil), // 14: alt.v1.Decimal + (Timeframe)(0), // 15: alt.v1.Timeframe + (*ErrorInfo)(nil), // 16: alt.v1.ErrorInfo } var file_alt_v1_market_proto_depIdxs = []int32{ - 9, // 0: alt.v1.Instrument.market:type_name -> alt.v1.Market - 10, // 1: alt.v1.Instrument.venue:type_name -> alt.v1.Venue - 11, // 2: alt.v1.Instrument.currency:type_name -> alt.v1.Currency - 8, // 3: alt.v1.Instrument.provider_symbols:type_name -> alt.v1.Instrument.ProviderSymbolsEntry - 11, // 4: alt.v1.Price.currency:type_name -> alt.v1.Currency - 12, // 5: alt.v1.Price.amount:type_name -> alt.v1.Decimal - 12, // 6: alt.v1.Quantity.amount:type_name -> alt.v1.Decimal - 13, // 7: alt.v1.Bar.timeframe:type_name -> alt.v1.Timeframe + 11, // 0: alt.v1.Instrument.market:type_name -> alt.v1.Market + 12, // 1: alt.v1.Instrument.venue:type_name -> alt.v1.Venue + 13, // 2: alt.v1.Instrument.currency:type_name -> alt.v1.Currency + 10, // 3: alt.v1.Instrument.provider_symbols:type_name -> alt.v1.Instrument.ProviderSymbolsEntry + 13, // 4: alt.v1.Price.currency:type_name -> alt.v1.Currency + 14, // 5: alt.v1.Price.amount:type_name -> alt.v1.Decimal + 14, // 6: alt.v1.Quantity.amount:type_name -> alt.v1.Decimal + 15, // 7: alt.v1.Bar.timeframe:type_name -> alt.v1.Timeframe 1, // 8: alt.v1.Bar.open:type_name -> alt.v1.Price 1, // 9: alt.v1.Bar.high:type_name -> alt.v1.Price 1, // 10: alt.v1.Bar.low:type_name -> alt.v1.Price 1, // 11: alt.v1.Bar.close:type_name -> alt.v1.Price 2, // 12: alt.v1.Bar.volume:type_name -> alt.v1.Quantity - 9, // 13: alt.v1.ListInstrumentsRequest.market:type_name -> alt.v1.Market + 11, // 13: alt.v1.ListInstrumentsRequest.market:type_name -> alt.v1.Market 0, // 14: alt.v1.ListInstrumentsResponse.instruments:type_name -> alt.v1.Instrument - 14, // 15: alt.v1.ListInstrumentsResponse.error:type_name -> alt.v1.ErrorInfo - 13, // 16: alt.v1.ListBarsRequest.timeframe:type_name -> alt.v1.Timeframe + 16, // 15: alt.v1.ListInstrumentsResponse.error:type_name -> alt.v1.ErrorInfo + 15, // 16: alt.v1.ListBarsRequest.timeframe:type_name -> alt.v1.Timeframe 3, // 17: alt.v1.ListBarsResponse.bars:type_name -> alt.v1.Bar - 14, // 18: alt.v1.ListBarsResponse.error:type_name -> alt.v1.ErrorInfo - 19, // [19:19] is the sub-list for method output_type - 19, // [19:19] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 16, // 18: alt.v1.ListBarsResponse.error:type_name -> alt.v1.ErrorInfo + 11, // 19: alt.v1.ImportDailyBarsRequest.market:type_name -> alt.v1.Market + 12, // 20: alt.v1.ImportDailyBarsRequest.venue:type_name -> alt.v1.Venue + 16, // 21: alt.v1.ImportDailyBarsResponse.error:type_name -> alt.v1.ErrorInfo + 22, // [22:22] is the sub-list for method output_type + 22, // [22:22] is the sub-list for method input_type + 22, // [22:22] is the sub-list for extension type_name + 22, // [22:22] is the sub-list for extension extendee + 0, // [0:22] is the sub-list for field type_name } func init() { file_alt_v1_market_proto_init() } @@ -650,7 +838,7 @@ func file_alt_v1_market_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_alt_v1_market_proto_rawDesc), len(file_alt_v1_market_proto_rawDesc)), NumEnums: 0, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/packages/contracts/proto/alt/v1/market.proto b/packages/contracts/proto/alt/v1/market.proto index f08982d..62ec569 100644 --- a/packages/contracts/proto/alt/v1/market.proto +++ b/packages/contracts/proto/alt/v1/market.proto @@ -57,3 +57,21 @@ message ListBarsResponse { repeated Bar bars = 1; ErrorInfo error = 2; } + +message ImportDailyBarsRequest { + string provider = 1; + string selector_kind = 2; + Market market = 3; + Venue venue = 4; + string name = 5; + repeated string symbols = 6; + string from_yyyymmdd = 7; + string to_yyyymmdd = 8; +} + +message ImportDailyBarsResponse { + string provider = 1; + int32 instrument_count = 2; + int32 bar_count = 3; + ErrorInfo error = 4; +} diff --git a/services/api/internal/contracts/parser_map.go b/services/api/internal/contracts/parser_map.go index 229b4a4..c802410 100644 --- a/services/api/internal/contracts/parser_map.go +++ b/services/api/internal/contracts/parser_map.go @@ -20,6 +20,9 @@ func messageFactories() []func() proto.Message { func() proto.Message { return &altv1.ListInstrumentsResponse{} }, func() proto.Message { return &altv1.ListBarsRequest{} }, func() proto.Message { return &altv1.ListBarsResponse{} }, + // market import command: daily bars + func() proto.Message { return &altv1.ImportDailyBarsRequest{} }, + func() proto.Message { return &altv1.ImportDailyBarsResponse{} }, // backtest surface: start / list / detail / result / compare func() proto.Message { return &altv1.StartBacktestRequest{} }, func() proto.Message { return &altv1.StartBacktestResponse{} }, diff --git a/services/api/internal/contracts/parser_map_test.go b/services/api/internal/contracts/parser_map_test.go index f7b099c..e883358 100644 --- a/services/api/internal/contracts/parser_map_test.go +++ b/services/api/internal/contracts/parser_map_test.go @@ -5,8 +5,8 @@ import ( "google.golang.org/protobuf/proto" - protoSocket "git.toki-labs.com/toki/proto-socket/go" altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" + protoSocket "git.toki-labs.com/toki/proto-socket/go" ) // requiredAPIMessages is the milestone contract surface the API parser map must @@ -22,6 +22,9 @@ func requiredAPIMessages() []proto.Message { &altv1.ListInstrumentsResponse{}, &altv1.ListBarsRequest{}, &altv1.ListBarsResponse{}, + // market import command: daily bars + &altv1.ImportDailyBarsRequest{}, + &altv1.ImportDailyBarsResponse{}, // backtest start / list / detail / result / compare &altv1.StartBacktestRequest{}, &altv1.StartBacktestResponse{}, diff --git a/services/api/internal/socket/backtest_test.go b/services/api/internal/socket/backtest_test.go index d4f680b..169cf88 100644 --- a/services/api/internal/socket/backtest_test.go +++ b/services/api/internal/socket/backtest_test.go @@ -22,6 +22,7 @@ type fakeWorkerClient struct { compareReq *altv1.CompareBacktestRunsRequest instReq *altv1.ListInstrumentsRequest barsReq *altv1.ListBarsRequest + importReq *altv1.ImportDailyBarsRequest startRes *altv1.StartBacktestResponse listRes *altv1.ListBacktestRunsResponse @@ -30,6 +31,7 @@ type fakeWorkerClient struct { compareRes *altv1.CompareBacktestRunsResponse instRes *altv1.ListInstrumentsResponse barsRes *altv1.ListBarsResponse + importRes *altv1.ImportDailyBarsResponse err error connectErr error @@ -81,6 +83,10 @@ func (f *fakeWorkerClient) ListBars(ctx context.Context, req *altv1.ListBarsRequ f.barsReq = req return f.barsRes, f.err } +func (f *fakeWorkerClient) ImportDailyBars(ctx context.Context, req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + f.importReq = req + return f.importRes, f.err +} func validStart() *altv1.StartBacktestRequest { return &altv1.StartBacktestRequest{Spec: &altv1.BacktestRunSpec{StrategyId: "strat-abc"}} diff --git a/services/api/internal/socket/handlers.go b/services/api/internal/socket/handlers.go index 0598737..7b75623 100644 --- a/services/api/internal/socket/handlers.go +++ b/services/api/internal/socket/handlers.go @@ -77,7 +77,7 @@ func handleHello(worker workerclient.WorkerClient, req *altv1.HelloRequest) (*al } func capabilitiesForSession(worker workerclient.WorkerClient) []string { - caps := []string{"hello", "request-response", "market-read", "backtest-read", "backtest-start", "worker-execution"} + caps := []string{"hello", "request-response", "market-read", "market-import", "backtest-read", "backtest-start", "worker-execution"} if worker != nil && worker.IsConnected() { caps = append(caps, "worker-available") } else { diff --git a/services/api/internal/socket/market.go b/services/api/internal/socket/market.go index 3931caa..afce715 100644 --- a/services/api/internal/socket/market.go +++ b/services/api/internal/socket/market.go @@ -3,6 +3,7 @@ package socket import ( "context" "errors" + "time" protoSocket "git.toki-labs.com/toki/proto-socket/go" @@ -10,6 +11,11 @@ import ( "git.toki-labs.com/toki/alt/services/api/internal/workerclient" ) +// importDateLayout matches the YYYYMMDD date fields the import command carries. +// The API only validates the shape so a malformed date is rejected before a +// worker connection is opened. +const importDateLayout = "20060102" + const ( marketErrorInvalidRequest = "invalid_request" marketErrorUnavailable = "unavailable" @@ -37,9 +43,61 @@ func marketHandlers(worker workerclient.WorkerClient) []sessionHandler { }) }, }, + { + requestType: protoSocket.TypeNameOf(&altv1.ImportDailyBarsRequest{}), + register: func(client *protoSocket.WsClient) { + protoSocket.AddRequestListenerTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse](&client.Communicator, func(req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + return handleImportDailyBars(worker, req) + }) + }, + }, } } +// handleImportDailyBars validates the import command shape, then forwards it to +// the worker which owns provider fetch and persistence. The API stays a thin +// control plane: it rejects malformed commands before opening a worker +// connection and never runs the import itself. +func handleImportDailyBars(worker workerclient.WorkerClient, req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + if req.GetProvider() == "" { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("provider is required")}, nil + } + if req.GetSelectorKind() == "" { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("selector_kind is required")}, nil + } + if len(req.GetSymbols()) == 0 { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("symbols is required")}, nil + } + if !isValidMarketFilter(req.GetMarket()) { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("market is unsupported")}, nil + } + if !isValidVenueFilter(req.GetVenue()) { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("venue is unsupported")}, nil + } + if err := validateImportDateRange(req.GetFromYyyymmdd(), req.GetToYyyymmdd()); err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil + } + if worker == nil { + return &altv1.ImportDailyBarsResponse{Error: marketWorkerUnavailable()}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout) + defer cancel() + + if err := worker.Connect(ctx); err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketWorkerErrorInfo(err)}, nil + } + + res, err := worker.ImportDailyBars(ctx, req) + if err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketWorkerErrorInfo(err)}, nil + } + if res == nil { + return &altv1.ImportDailyBarsResponse{Error: marketInternalError("worker returned no import daily bars response")}, nil + } + return res, nil +} + func handleListInstruments(worker workerclient.WorkerClient, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) { if !isValidMarketFilter(req.GetMarket()) { return &altv1.ListInstrumentsResponse{Error: marketInvalidRequest("market filter is unsupported")}, nil @@ -111,6 +169,42 @@ func isValidMarketFilter(m altv1.Market) bool { } } +// isValidVenueFilter accepts the unspecified venue (the import selector may not +// pin a venue) and the known venues, rejecting only out-of-range enum values. +func isValidVenueFilter(v altv1.Venue) bool { + switch v { + case altv1.Venue_VENUE_UNSPECIFIED, altv1.Venue_VENUE_KRX, altv1.Venue_VENUE_NASDAQ, altv1.Venue_VENUE_NYSE: + return true + default: + return false + } +} + +// validateImportDateRange requires both endpoints of the import date range. The +// live KIS importer requires a bounded from/to range and rejects an inverted +// range, so the API closes those operator-input errors here as typed +// invalid_request instead of letting them surface as worker-internal failures. +func validateImportDateRange(fromValue, toValue string) error { + if fromValue == "" { + return errors.New("from_yyyymmdd is required") + } + if toValue == "" { + return errors.New("to_yyyymmdd is required") + } + from, err := time.Parse(importDateLayout, fromValue) + if err != nil { + return errors.New("from_yyyymmdd must be YYYYMMDD") + } + to, err := time.Parse(importDateLayout, toValue) + if err != nil { + return errors.New("to_yyyymmdd must be YYYYMMDD") + } + if from.After(to) { + return errors.New("from_yyyymmdd cannot be after to_yyyymmdd") + } + return nil +} + func isValidTimeframe(t altv1.Timeframe) bool { switch t { case altv1.Timeframe_TIMEFRAME_DAILY, altv1.Timeframe_TIMEFRAME_MINUTE_1, altv1.Timeframe_TIMEFRAME_MINUTE_5: diff --git a/services/api/internal/socket/market_test.go b/services/api/internal/socket/market_test.go index 67389c2..bbf4d87 100644 --- a/services/api/internal/socket/market_test.go +++ b/services/api/internal/socket/market_test.go @@ -203,6 +203,7 @@ func TestMarketHandlersRegisteredInSession(t *testing.T) { required := []string{ protoSocket.TypeNameOf(&altv1.ListInstrumentsRequest{}), protoSocket.TypeNameOf(&altv1.ListBarsRequest{}), + protoSocket.TypeNameOf(&altv1.ImportDailyBarsRequest{}), } for _, req := range required { if !registered[req] { @@ -211,6 +212,162 @@ func TestMarketHandlersRegisteredInSession(t *testing.T) { } } +func validImportRequest() *altv1.ImportDailyBarsRequest { + return &altv1.ImportDailyBarsRequest{ + Provider: "kis", + SelectorKind: "watchlist", + Market: altv1.Market_MARKET_KR, + Venue: altv1.Venue_VENUE_KRX, + Symbols: []string{"005930"}, + FromYyyymmdd: "20260501", + ToYyyymmdd: "20260515", + } +} + +func TestHandleImportDailyBarsForwards(t *testing.T) { + fake := &fakeWorkerClient{importRes: &altv1.ImportDailyBarsResponse{ + Provider: "kis", InstrumentCount: 1, BarCount: 10, + }} + + resp, err := handleImportDailyBars(fake, validImportRequest()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if fake.importReq == nil { + t.Fatal("expected request to be forwarded to worker") + } + if fake.importReq.GetProvider() != "kis" || len(fake.importReq.GetSymbols()) != 1 { + t.Errorf("request was not forwarded unchanged: %+v", fake.importReq) + } + if resp.GetInstrumentCount() != 1 || resp.GetBarCount() != 10 { + t.Errorf("unexpected response counts: %+v", resp) + } +} + +func TestHandleImportDailyBarsValidation(t *testing.T) { + // withImport starts from a fully valid request and mutates one field so each + // case isolates a single validation failure. + withImport := func(mut func(*altv1.ImportDailyBarsRequest)) *altv1.ImportDailyBarsRequest { + req := validImportRequest() + mut(req) + return req + } + tests := []struct { + name string + req *altv1.ImportDailyBarsRequest + }{ + {"missing provider", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Provider = "" })}, + {"missing selector kind", withImport(func(r *altv1.ImportDailyBarsRequest) { r.SelectorKind = "" })}, + {"missing symbols", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Symbols = nil })}, + {"unsupported market", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Market = altv1.Market(99) })}, + {"unsupported venue", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Venue = altv1.Venue(99) })}, + {"missing from date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.FromYyyymmdd = "" })}, + {"missing to date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.ToYyyymmdd = "" })}, + {"malformed from date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.FromYyyymmdd = "2026-05-01" })}, + {"malformed to date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.ToYyyymmdd = "not-a-date" })}, + {"reversed range", withImport(func(r *altv1.ImportDailyBarsRequest) { + r.FromYyyymmdd, r.ToYyyymmdd = r.GetToYyyymmdd(), r.GetFromYyyymmdd() + })}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeWorkerClient{} + + resp, err := handleImportDailyBars(fake, tt.req) + if err != nil { + t.Fatalf("expected typed validation response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorInvalidRequest) + if fake.importReq != nil { + t.Error("worker must not be called when validation fails") + } + if fake.connectCount != 0 { + t.Errorf("worker must not be connected when validation fails, got connectCount=%d", fake.connectCount) + } + }) + } +} + +func TestHandleImportDailyBarsNilWorker(t *testing.T) { + resp, err := handleImportDailyBars(nil, validImportRequest()) + if err != nil { + t.Fatalf("expected typed unavailable response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorUnavailable) +} + +func TestHandleImportDailyBarsNilWorkerResponse(t *testing.T) { + fake := &fakeWorkerClient{} + + resp, err := handleImportDailyBars(fake, validImportRequest()) + if err != nil { + t.Fatalf("expected typed internal response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorInternal) + if fake.importReq == nil { + t.Error("expected request to be forwarded before nil response was detected") + } +} + +func TestHandleImportDailyBarsMapsWorkerErrors(t *testing.T) { + tests := []struct { + name string + err error + code string + }{ + {"unavailable", workerclient.ErrUnavailable, marketErrorUnavailable}, + {"timeout", workerclient.ErrTimeout, marketErrorTimeout}, + {"unexpected", context.Canceled, marketErrorInternal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeWorkerClient{err: tt.err} + + resp, err := handleImportDailyBars(fake, validImportRequest()) + if err != nil { + t.Fatalf("expected typed worker error response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), tt.code) + if fake.importReq == nil { + t.Error("valid request should be forwarded before worker error is mapped") + } + }) + } +} + +func TestHandleImportDailyBars_ConnectFailure(t *testing.T) { + fake := &fakeWorkerClient{connectErr: workerclient.ErrUnavailable} + + resp, err := handleImportDailyBars(fake, validImportRequest()) + if err != nil { + t.Fatalf("expected typed unavailable response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorUnavailable) + if fake.importReq != nil { + t.Error("expected request not to be forwarded to worker on connect failure") + } +} + +func TestImportSocketValidationReturnsTypedErrors(t *testing.T) { + fake := &fakeWorkerClient{} + client := startBacktestAPITestClient(t, fake) + + resp, err := protoSocket.SendRequestTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse]( + &client.Communicator, + &altv1.ImportDailyBarsRequest{}, + 500*time.Millisecond, + ) + if err != nil { + t.Fatalf("request should return typed error response without timeout: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorInvalidRequest) + if fake.importReq != nil { + t.Fatal("worker must not be called for socket-level validation failures") + } +} + func requireMarketError(t *testing.T, errInfo *altv1.ErrorInfo, code string) { t.Helper() if errInfo == nil { diff --git a/services/api/internal/socket/server_test.go b/services/api/internal/socket/server_test.go index 52938d4..efb2f3f 100644 --- a/services/api/internal/socket/server_test.go +++ b/services/api/internal/socket/server_test.go @@ -65,6 +65,7 @@ func TestServerRespondsToHelloRequest(t *testing.T) { "hello": true, "request-response": true, "market-read": true, + "market-import": true, "backtest-read": true, "backtest-start": true, "worker-execution": true, @@ -123,6 +124,7 @@ func TestServerRespondsToHelloRequest_WorkerAvailable(t *testing.T) { "hello": true, "request-response": true, "market-read": true, + "market-import": true, "backtest-read": true, "backtest-start": true, "worker-execution": true, @@ -315,6 +317,11 @@ func TestCapabilitiesAndHandlersSync(t *testing.T) { t.Error("market-read capability missing although ListInstruments/ListBars handlers are registered") } + // 1b. Check ImportDailyBars -> market-import + if registered[protoSocket.TypeNameOf(&altv1.ImportDailyBarsRequest{})] && !capSet["market-import"] { + t.Error("market-import capability missing although ImportDailyBars handler is registered") + } + // 2. Check backtest queries -> backtest-read hasBacktestReadRequest := registered[protoSocket.TypeNameOf(&altv1.ListBacktestRunsRequest{})] && registered[protoSocket.TypeNameOf(&altv1.GetBacktestRunDetailRequest{})] && diff --git a/services/api/internal/workerclient/client.go b/services/api/internal/workerclient/client.go index b524fd8..74c55a3 100644 --- a/services/api/internal/workerclient/client.go +++ b/services/api/internal/workerclient/client.go @@ -40,6 +40,10 @@ type WorkerClient interface { // reads to the worker-owned storage boundary. ListInstruments(ctx context.Context, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) ListBars(ctx context.Context, req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) + + // Market import command surface. Import execution runs inside the worker; the + // API only validates shape and forwards the command. + ImportDailyBars(ctx context.Context, req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) } type socketClient struct { @@ -152,6 +156,10 @@ func (c *socketClient) ListBars(ctx context.Context, req *altv1.ListBarsRequest) return sendTyped[*altv1.ListBarsRequest, *altv1.ListBarsResponse](c, ctx, req) } +func (c *socketClient) ImportDailyBars(ctx context.Context, req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + return sendTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse](c, ctx, req) +} + // sendTyped is the shared request path for every worker call. It centralises // context-cancellation, deadline-derived timeouts, and the unavailable/timeout // error mapping so each new request method stays a one-line forwarder and the diff --git a/services/api/internal/workerclient/client_test.go b/services/api/internal/workerclient/client_test.go index 85f9a70..d442188 100644 --- a/services/api/internal/workerclient/client_test.go +++ b/services/api/internal/workerclient/client_test.go @@ -194,6 +194,56 @@ func TestWorkerClient_ListBars_Success(t *testing.T) { } } +func TestWorkerClient_ImportDailyBars_Success(t *testing.T) { + port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) { + protoSocket.AddRequestListenerTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse](&client.Communicator, func(req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + return &altv1.ImportDailyBarsResponse{ + Provider: req.GetProvider(), + InstrumentCount: int32(len(req.GetSymbols())), + BarCount: 2, + }, nil + }) + }) + defer cleanup() + + client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port)) + ctx := context.Background() + if err := client.Connect(ctx); err != nil { + t.Fatalf("failed to connect: %v", err) + } + defer client.Close() + + res, err := client.ImportDailyBars(ctx, &altv1.ImportDailyBarsRequest{ + Provider: "kis", + SelectorKind: "watchlist", + Market: altv1.Market_MARKET_KR, + Venue: altv1.Venue_VENUE_KRX, + Symbols: []string{"005930"}, + }) + if err != nil { + t.Fatalf("ImportDailyBars failed: %v", err) + } + if res.GetProvider() != "kis" { + t.Errorf("provider did not round-trip, got %q", res.GetProvider()) + } + if res.GetInstrumentCount() != 1 || res.GetBarCount() != 2 { + t.Errorf("counts did not round-trip, got instruments=%d bars=%d", res.GetInstrumentCount(), res.GetBarCount()) + } +} + +func TestWorkerClient_ImportDailyBars_Unavailable(t *testing.T) { + // A never-connected client must report ErrUnavailable instead of blocking. + client := New("ws://127.0.0.1:54321/socket") + _, err := client.ImportDailyBars(context.Background(), &altv1.ImportDailyBarsRequest{ + Provider: "kis", + SelectorKind: "watchlist", + Symbols: []string{"005930"}, + }) + if !errors.Is(err, ErrUnavailable) { + t.Errorf("expected ErrUnavailable, got %v", err) + } +} + func TestWorkerClient_StartBacktest_Unavailable(t *testing.T) { // A client that was never connected must report ErrUnavailable rather than // panicking or blocking, exercising the shared sendTyped guard. diff --git a/services/worker/cmd/alt-worker/main.go b/services/worker/cmd/alt-worker/main.go index f007849..fa527ed 100644 --- a/services/worker/cmd/alt-worker/main.go +++ b/services/worker/cmd/alt-worker/main.go @@ -38,12 +38,17 @@ func main() { defer pool.Close() store := postgres.NewStore(pool) kisProvider := kis.NewLiveProvider(kis.NewClient(kis.ConfigFromEnv())) - jobs.RegisterDailyBarImportHandler(runner, market.ProviderKIS, importer.New(kisProvider, store)) + // One importer instance backs both the async job handler and the socket + // import command so the operator-facing command and scheduled jobs share + // the same provider/store wiring. + kisImporter := importer.New(kisProvider, store) + jobs.RegisterDailyBarImportHandler(runner, market.ProviderKIS, kisImporter) deps = socket.Deps{ - Analysis: store, - Results: store, - Instruments: store, - Bars: store, + Analysis: store, + Results: store, + Instruments: store, + Bars: store, + DailyBarImporter: kisImporter, } } @@ -60,6 +65,7 @@ func main() { "backtest_read_surface", deps.Analysis != nil, "backtest_start_surface", deps.Starter != nil, "market_read_surface", deps.Instruments != nil && deps.Bars != nil, + "market_import_surface", deps.DailyBarImporter != nil, "addr", fmt.Sprintf("%s:%d%s", cfg.Host, cfg.Port, cfg.SocketPath), ) diff --git a/services/worker/internal/contracts/parser_map.go b/services/worker/internal/contracts/parser_map.go index 5bbb7e7..74d740c 100644 --- a/services/worker/internal/contracts/parser_map.go +++ b/services/worker/internal/contracts/parser_map.go @@ -16,6 +16,9 @@ func messageFactories() []func() proto.Message { func() proto.Message { return &altv1.ListInstrumentsResponse{} }, func() proto.Message { return &altv1.ListBarsRequest{} }, func() proto.Message { return &altv1.ListBarsResponse{} }, + // market import command: daily bars + func() proto.Message { return &altv1.ImportDailyBarsRequest{} }, + func() proto.Message { return &altv1.ImportDailyBarsResponse{} }, // backtest surface: start / list / detail / result / compare func() proto.Message { return &altv1.StartBacktestRequest{} }, func() proto.Message { return &altv1.StartBacktestResponse{} }, diff --git a/services/worker/internal/contracts/parser_map_test.go b/services/worker/internal/contracts/parser_map_test.go index c01dc94..02e790f 100644 --- a/services/worker/internal/contracts/parser_map_test.go +++ b/services/worker/internal/contracts/parser_map_test.go @@ -18,6 +18,9 @@ func requiredWorkerMessages() []proto.Message { &altv1.ListInstrumentsResponse{}, &altv1.ListBarsRequest{}, &altv1.ListBarsResponse{}, + // market import command: daily bars + &altv1.ImportDailyBarsRequest{}, + &altv1.ImportDailyBarsResponse{}, // backtest start / list / detail / result / compare &altv1.StartBacktestRequest{}, &altv1.StartBacktestResponse{}, diff --git a/services/worker/internal/socket/backtest.go b/services/worker/internal/socket/backtest.go index 584ecfd..9fe83f4 100644 --- a/services/worker/internal/socket/backtest.go +++ b/services/worker/internal/socket/backtest.go @@ -9,6 +9,7 @@ import ( altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" "git.toki-labs.com/toki/alt/packages/domain/backtest" + "git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer" "git.toki-labs.com/toki/alt/services/worker/internal/storage" ) @@ -32,15 +33,23 @@ type BacktestStarter interface { StartBacktest(ctx context.Context, spec backtest.RunSpec) (backtest.Run, error) } +// DailyBarImporter runs a daily bar import command. The marketdata importer +// provides the concrete implementation; the narrow interface keeps the socket +// handler testable with a fake and decoupled from provider/storage wiring. +type DailyBarImporter interface { + ImportDailyBars(ctx context.Context, request importer.DailyBarRequest) (importer.Result, error) +} + // Deps bundles the worker-owned capabilities socket handlers need. A nil field // disables only its related handler surface so the server can still run a // reduced surface (e.g. hello-only) without panicking. type Deps struct { - Starter BacktestStarter - Analysis storage.BacktestAnalysisStore - Results storage.BacktestResultStore - Instruments storage.InstrumentStore - Bars storage.BarStore + Starter BacktestStarter + Analysis storage.BacktestAnalysisStore + Results storage.BacktestResultStore + Instruments storage.InstrumentStore + Bars storage.BarStore + DailyBarImporter DailyBarImporter } // BacktestDeps is retained for existing call sites while the socket dependency diff --git a/services/worker/internal/socket/handlers.go b/services/worker/internal/socket/handlers.go index 084f027..500d326 100644 --- a/services/worker/internal/socket/handlers.go +++ b/services/worker/internal/socket/handlers.go @@ -73,6 +73,9 @@ func capabilitiesForDeps(deps Deps) []string { if deps.Instruments != nil && deps.Bars != nil { caps = append(caps, "market-read") } + if deps.DailyBarImporter != nil { + caps = append(caps, "market-import") + } if deps.Analysis != nil && deps.Results != nil { caps = append(caps, "backtest-read") } diff --git a/services/worker/internal/socket/market.go b/services/worker/internal/socket/market.go index b46fc07..2f1e953 100644 --- a/services/worker/internal/socket/market.go +++ b/services/worker/internal/socket/market.go @@ -9,9 +9,14 @@ import ( altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" "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/storage" ) +// importDateLayout matches the YYYYMMDD date fields the import command carries, +// mirroring the job payload date contract so the same dates reach the provider. +const importDateLayout = "20060102" + const ( marketErrorInvalidRequest = "invalid_request" marketErrorUnavailable = "unavailable" @@ -44,9 +49,100 @@ func marketHandlers(deps Deps) []sessionHandler { ) }, }, + { + requestType: protoSocket.TypeNameOf(&altv1.ImportDailyBarsRequest{}), + register: func(client *protoSocket.WsClient) { + protoSocket.AddRequestListenerTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse]( + &client.Communicator, + func(req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + return handleImportDailyBars(deps, req) + }, + ) + }, + }, } } +// handleImportDailyBars validates the import command, converts it into the +// importer request, and runs the import. The worker owns provider fetch and +// persistence; counts come back so the operator surface can confirm what landed. +func handleImportDailyBars(deps Deps, req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + if req.GetProvider() == "" { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("provider is required")}, nil + } + if req.GetSelectorKind() == "" { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("selector_kind is required")}, nil + } + if len(req.GetSymbols()) == 0 { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest("symbols is required")}, nil + } + mkt, _, err := marketFilterFromProto(req.GetMarket()) + if err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil + } + venue, err := venueFromProto(req.GetVenue()) + if err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil + } + from, to, err := parseImportDateRange(req.GetFromYyyymmdd(), req.GetToYyyymmdd()) + if err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketInvalidRequest(err.Error())}, nil + } + if deps.DailyBarImporter == nil { + return &altv1.ImportDailyBarsResponse{Error: marketUnavailableError("daily bar import is not available")}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout) + defer cancel() + + result, err := deps.DailyBarImporter.ImportDailyBars(ctx, importer.DailyBarRequest{ + Provider: market.Provider(req.GetProvider()), + Selector: market.UniverseSelector{ + Kind: market.UniverseSelectorKind(req.GetSelectorKind()), + Market: mkt, + Venue: venue, + Symbols: req.GetSymbols(), + Name: req.GetName(), + }, + From: from, + To: to, + }) + if err != nil { + return &altv1.ImportDailyBarsResponse{Error: marketBackendErrorInfo(err)}, nil + } + return &altv1.ImportDailyBarsResponse{ + Provider: req.GetProvider(), + InstrumentCount: int32(result.Instruments), + BarCount: int32(result.Bars), + }, nil +} + +// parseImportDateRange requires both endpoints of the import date range and +// returns them parsed for the importer request. The live KIS importer requires +// a bounded from/to range and rejects an inverted range, so the worker closes +// those operator-input errors here as invalid_request before reaching the +// importer instead of letting them surface as an internal backend failure. +func parseImportDateRange(fromValue, toValue string) (time.Time, time.Time, error) { + if fromValue == "" { + return time.Time{}, time.Time{}, errors.New("from_yyyymmdd is required") + } + if toValue == "" { + return time.Time{}, time.Time{}, errors.New("to_yyyymmdd is required") + } + from, err := time.Parse(importDateLayout, fromValue) + if err != nil { + return time.Time{}, time.Time{}, errors.New("from_yyyymmdd must be YYYYMMDD") + } + to, err := time.Parse(importDateLayout, toValue) + if err != nil { + return time.Time{}, time.Time{}, errors.New("to_yyyymmdd must be YYYYMMDD") + } + if from.After(to) { + return time.Time{}, time.Time{}, errors.New("from_yyyymmdd cannot be after to_yyyymmdd") + } + return from.UTC(), to.UTC(), nil +} + func handleListInstruments(deps Deps, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) { marketFilter, hasMarketFilter, err := marketFilterFromProto(req.GetMarket()) if err != nil { diff --git a/services/worker/internal/socket/market_mapping.go b/services/worker/internal/socket/market_mapping.go index f87d96b..1bd88b8 100644 --- a/services/worker/internal/socket/market_mapping.go +++ b/services/worker/internal/socket/market_mapping.go @@ -1,6 +1,8 @@ package socket import ( + "fmt" + altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" "git.toki-labs.com/toki/alt/packages/domain/market" ) @@ -46,6 +48,24 @@ func barsToProto(bars []market.Bar) []*altv1.Bar { return out } +// venueFromProto converts a proto venue to the domain venue. The unspecified +// venue maps to an empty domain venue because an import selector may not pin a +// venue (e.g. a watchlist selector); only out-of-range enum values are an error. +func venueFromProto(v altv1.Venue) (market.Venue, error) { + switch v { + case altv1.Venue_VENUE_UNSPECIFIED: + return "", nil + case altv1.Venue_VENUE_KRX: + return market.VenueKRX, nil + case altv1.Venue_VENUE_NASDAQ: + return market.VenueNASDAQ, nil + case altv1.Venue_VENUE_NYSE: + return market.VenueNYSE, nil + default: + return "", fmt.Errorf("unsupported venue %q", v.String()) + } +} + func venueToProto(v market.Venue) altv1.Venue { switch v { case market.VenueKRX: diff --git a/services/worker/internal/socket/market_test.go b/services/worker/internal/socket/market_test.go index 37773be..d66d94e 100644 --- a/services/worker/internal/socket/market_test.go +++ b/services/worker/internal/socket/market_test.go @@ -8,6 +8,7 @@ import ( altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" "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/storage" protoSocket "git.toki-labs.com/toki/proto-socket/go" ) @@ -50,6 +51,32 @@ func (f *fakeBarStore) GetBars(ctx context.Context, id market.InstrumentID, time return f.bars, f.err } +type fakeDailyBarImporter struct { + gotReq importer.DailyBarRequest + called bool + result importer.Result + err error +} + +func (f *fakeDailyBarImporter) ImportDailyBars(ctx context.Context, request importer.DailyBarRequest) (importer.Result, error) { + f.called = true + f.gotReq = request + return f.result, f.err +} + +func validImportRequest() *altv1.ImportDailyBarsRequest { + return &altv1.ImportDailyBarsRequest{ + Provider: "kis", + SelectorKind: "watchlist", + Market: altv1.Market_MARKET_KR, + Venue: altv1.Venue_VENUE_KRX, + Name: "my-watchlist", + Symbols: []string{"005930", "000660"}, + FromYyyymmdd: "20260501", + ToYyyymmdd: "20260515", + } +} + func validListBarsRequest() *altv1.ListBarsRequest { return &altv1.ListBarsRequest{ InstrumentId: "KRX:005930", @@ -192,6 +219,133 @@ func TestHandleListBarsNotFound(t *testing.T) { requireMarketError(t, resp.GetError(), marketErrorNotFound) } +func TestHandleImportDailyBarsSuccess(t *testing.T) { + imp := &fakeDailyBarImporter{result: importer.Result{Instruments: 2, Bars: 20}} + + resp, err := handleImportDailyBars(Deps{DailyBarImporter: imp}, validImportRequest()) + 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") + } + if resp.GetProvider() != "kis" || resp.GetInstrumentCount() != 2 || resp.GetBarCount() != 20 { + t.Errorf("unexpected response: %+v", resp) + } +} + +func TestHandleImportDailyBarsConvertsRequest(t *testing.T) { + imp := &fakeDailyBarImporter{} + + if _, err := handleImportDailyBars(Deps{DailyBarImporter: imp}, validImportRequest()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := imp.gotReq + if got.Provider != market.ProviderKIS { + t.Errorf("provider mismatch: %q", got.Provider) + } + if got.Selector.Kind != market.UniverseSelectorWatchlist { + t.Errorf("selector kind mismatch: %q", got.Selector.Kind) + } + if got.Selector.Market != market.MarketKR || got.Selector.Venue != market.VenueKRX { + t.Errorf("selector market/venue mismatch: %q/%q", got.Selector.Market, got.Selector.Venue) + } + if got.Selector.Name != "my-watchlist" || len(got.Selector.Symbols) != 2 { + t.Errorf("selector name/symbols mismatch: %+v", got.Selector) + } + wantFrom := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + wantTo := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC) + if !got.From.Equal(wantFrom) || !got.To.Equal(wantTo) { + t.Errorf("date range mismatch: got %v..%v", got.From, got.To) + } +} + +func TestHandleImportDailyBarsValidation(t *testing.T) { + // withImport starts from a fully valid request and mutates one field so each + // case isolates a single validation failure. + withImport := func(mut func(*altv1.ImportDailyBarsRequest)) *altv1.ImportDailyBarsRequest { + req := validImportRequest() + mut(req) + return req + } + tests := []struct { + name string + req *altv1.ImportDailyBarsRequest + }{ + {"missing provider", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Provider = "" })}, + {"missing selector kind", withImport(func(r *altv1.ImportDailyBarsRequest) { r.SelectorKind = "" })}, + {"missing symbols", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Symbols = nil })}, + {"unsupported market", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Market = altv1.Market(99) })}, + {"unsupported venue", withImport(func(r *altv1.ImportDailyBarsRequest) { r.Venue = altv1.Venue(99) })}, + {"missing from date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.FromYyyymmdd = "" })}, + {"missing to date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.ToYyyymmdd = "" })}, + {"malformed from date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.FromYyyymmdd = "2026-05-01" })}, + {"malformed to date", withImport(func(r *altv1.ImportDailyBarsRequest) { r.ToYyyymmdd = "nope" })}, + {"reversed range", withImport(func(r *altv1.ImportDailyBarsRequest) { + r.FromYyyymmdd, r.ToYyyymmdd = r.GetToYyyymmdd(), r.GetFromYyyymmdd() + })}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imp := &fakeDailyBarImporter{} + resp, err := handleImportDailyBars(Deps{DailyBarImporter: imp}, tt.req) + if err != nil { + t.Fatalf("expected typed validation response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorInvalidRequest) + if imp.called { + t.Error("importer must not be called when validation fails") + } + }) + } +} + +func TestHandleImportDailyBarsUnavailable(t *testing.T) { + resp, err := handleImportDailyBars(Deps{}, validImportRequest()) + if err != nil { + t.Fatalf("expected typed unavailable response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorUnavailable) +} + +func TestHandleImportDailyBarsImporterError(t *testing.T) { + imp := &fakeDailyBarImporter{err: errors.New("provider boom")} + + resp, err := handleImportDailyBars(Deps{DailyBarImporter: imp}, validImportRequest()) + if err != nil { + t.Fatalf("expected typed internal response, got error: %v", err) + } + requireMarketError(t, resp.GetError(), marketErrorInternal) +} + +func TestWorkerImportSocketRoundTrip(t *testing.T) { + imp := &fakeDailyBarImporter{result: importer.Result{Instruments: 1, Bars: 5}} + client := startBacktestWorkerTestClient(t, Deps{DailyBarImporter: imp}) + + resp, err := protoSocket.SendRequestTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse]( + &client.Communicator, + validImportRequest(), + 500*time.Millisecond, + ) + if err != nil { + t.Fatalf("request should return import response without timeout: %v", err) + } + if resp.GetError() != nil { + t.Fatalf("unexpected import error: %+v", resp.GetError()) + } + if resp.GetInstrumentCount() != 1 || resp.GetBarCount() != 5 { + t.Fatalf("unexpected counts: %+v", resp) + } + if !imp.called { + t.Fatal("expected importer to be called through socket") + } +} + func TestMarketHandlersCoverAllRequests(t *testing.T) { registered := make(map[string]bool) for _, h := range marketHandlers(Deps{}) { @@ -207,6 +361,7 @@ func TestMarketHandlersCoverAllRequests(t *testing.T) { required := []string{ "alt.v1.ListInstrumentsRequest", "alt.v1.ListBarsRequest", + "alt.v1.ImportDailyBarsRequest", } for _, req := range required { if !registered[req] { diff --git a/services/worker/internal/socket/server_test.go b/services/worker/internal/socket/server_test.go index 1f1542e..d2c7e7d 100644 --- a/services/worker/internal/socket/server_test.go +++ b/services/worker/internal/socket/server_test.go @@ -131,6 +131,13 @@ func TestWorkerHelloCapabilitiesReflectDeps(t *testing.T) { }, expected: []string{"hello", "market-read"}, }, + { + name: "import deps", + deps: Deps{ + DailyBarImporter: &fakeDailyBarImporter{}, + }, + expected: []string{"hello", "market-import"}, + }, { name: "backtest read deps", deps: Deps{ @@ -149,13 +156,14 @@ func TestWorkerHelloCapabilitiesReflectDeps(t *testing.T) { { name: "all deps", deps: Deps{ - Instruments: &fakeInstrumentStore{}, - Bars: &fakeBarStore{}, - Analysis: &fakeAnalysisStore{}, - Results: &fakeResultStore{}, - Starter: &fakeStarter{}, + Instruments: &fakeInstrumentStore{}, + Bars: &fakeBarStore{}, + Analysis: &fakeAnalysisStore{}, + Results: &fakeResultStore{}, + Starter: &fakeStarter{}, + DailyBarImporter: &fakeDailyBarImporter{}, }, - expected: []string{"hello", "market-read", "backtest-read", "backtest-start", "worker-execution"}, + expected: []string{"hello", "market-read", "market-import", "backtest-read", "backtest-start", "worker-execution"}, }, }