refactor: market status parsing과 backtest runtime 흐름 통합

- parser_map.go 업데이트하여 market status 파싱 로직 통일
- protobuf market.proto 변경사항 적용 (market.pb.go, market.pb.dart)
- socket handlers, market, backtest 관련 테스트 및 런타임 코드 개선
- workerclient와 alt-worker main.go 변경사항 반영
- agent-task archive 이동 (01_import_contract_worker_api → archive/2026/06/)
This commit is contained in:
toki 2026-06-03 20:26:56 +09:00
parent 7134a2a0e2
commit 620ca92e2e
30 changed files with 1877 additions and 200 deletions

View file

@ -0,0 +1,222 @@
<!-- task=m-command-first-operator-workflow/01_import_contract_worker_api plan=0 tag=API -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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`를 작성한다.

View file

@ -0,0 +1,211 @@
<!-- task=m-command-first-operator-workflow/01_import_contract_worker_api plan=1 tag=REVIEW_API -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,220 @@
<!-- task=m-command-first-operator-workflow/01_import_contract_worker_api plan=1 tag=REVIEW_API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,155 +0,0 @@
<!-- task=m-command-first-operator-workflow/01_import_contract_worker_api plan=0 tag=API -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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.

View file

@ -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{} },

View file

@ -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{},

View file

@ -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<ImportDailyBarsRequest>(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<ImportDailyBarsResponse>(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 =

View file

@ -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'
'==');

View file

@ -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,
},

View file

@ -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;
}

View file

@ -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{} },

View file

@ -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{},

View file

@ -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"}}

View file

@ -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 {

View file

@ -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:

View file

@ -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 {

View file

@ -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{})] &&

View file

@ -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

View file

@ -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.

View file

@ -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),
)

View file

@ -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{} },

View file

@ -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{},

View file

@ -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

View file

@ -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")
}

View file

@ -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 {

View file

@ -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:

View file

@ -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] {

View file

@ -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"},
},
}