diff --git a/agent-task/m-command-first-operator-workflow/02+01_import_cli_scenario/CODE_REVIEW-cloud-G07.md b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_0.log similarity index 63% rename from agent-task/m-command-first-operator-workflow/02+01_import_cli_scenario/CODE_REVIEW-cloud-G07.md rename to agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_0.log index 0ae6733..6c88761 100644 --- a/agent-task/m-command-first-operator-workflow/02+01_import_cli_scenario/CODE_REVIEW-cloud-G07.md +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_0.log @@ -41,51 +41,50 @@ task=m-command-first-operator-workflow/02+01_import_cli_scenario, plan=0, tag=CL | 항목 | 완료 여부 | |------|---------| -| [CLI-1] Scenario Schema | [ ] | -| [CLI-2] API Client Method | [ ] | -| [CLI-3] Runner And Output | [ ] | -| [CLI-4] Import Fixture | [ ] | -| [CLI-5] Verification | [ ] | +| [CLI-1] Scenario Schema | [x] | +| [CLI-2] API Client Method | [x] | +| [CLI-3] Runner And Output | [x] | +| [CLI-4] Import Fixture | [x] | +| [CLI-5] Verification | [x] | ## 구현 체크리스트 -- [ ] [CLI-1] Add `import_daily_bars` scenario schema fields and dry-run validation. -- [ ] [CLI-2] Add CLI `APIClient.ImportDailyBars` and fake API test support. -- [ ] [CLI-3] Execute import action in runner and emit `provider`, `instrument_count`, and `bar_count` output keys. -- [ ] [CLI-4] Add import smoke YAML and expected JSONL fixture. 검증: fake/provider test and scenario validation confirm import result counts are emitted. -- [ ] [CLI-5] Run CLI-focused verification and `git diff --check`. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] [CLI-1] Add `import_daily_bars` scenario schema fields and dry-run validation. +- [x] [CLI-2] Add CLI `APIClient.ImportDailyBars` and fake API test support. +- [x] [CLI-3] Execute import action in runner and emit `provider`, `instrument_count`, and `bar_count` output keys. +- [x] [CLI-4] Add import smoke YAML and expected JSONL fixture. 검증: fake/provider test and scenario validation confirm import result counts are emitted. +- [x] [CLI-5] Run CLI-focused verification and `git diff --check`. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md` and `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/02+01_import_cli_scenario/`를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/02+01_import_cli_scenario/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. - [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. - [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-command-first-operator-workflow/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +없음. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- `validateRequest`에 `ActionImportDailyBars` 전용 validation 로직을 추가하여 소켓이 연결되기 전 날짜 오름차순, 필수 인자 누락 및 유효하지 않은 provider/market/venue에 대해 오프라인에서 철저하게 가드하도록 구현했습니다. +- `ImportDailyBars` 응답 처리 시 `provider`, `instrument_count`, `bar_count` 필드를 `StepEvent`에 담아 JSONL 및 텍스트 로그 출력 양식에 맞게 기록하도록 `evaluateImport` 헬퍼를 신설하여 일관된 평가 흐름을 유지했습니다. ## 사용자 리뷰 요청 -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - - 상태: 없음 - 사유 유형: 없음 - 결정 필요: 없음 @@ -104,44 +103,36 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외 ## 검증 결과 -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. -- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. - ### CLI-1 중간 검증 ```bash $ go test ./apps/cli/internal/operator -run 'Scenario|ImportDailyBars' -count=1 -(output) +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.015s ``` ### CLI-2 중간 검증 ```bash $ go test ./apps/cli/internal/operator -run APIClientImportDailyBars -count=1 -(output) +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.006s ``` ### CLI-3 중간 검증 ```bash $ go test ./apps/cli/internal/operator -run 'RunScenario.*Import|ExpectedTypedError' -count=1 -(output) +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.009s ``` ### CLI-4 중간 검증 ```bash $ go test ./apps/cli/internal/operator -run 'Handoff|ImportDailyBars' -count=1 $ go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml -(output) +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.016s +scenario=kis_daily_import_smoke status=valid steps=1 ``` ### CLI-5 중간 검증 ```bash $ git diff --check -(output) +(exit code 0, no output) ``` ### 최종 검증 @@ -149,7 +140,10 @@ $ git diff --check $ go test ./apps/cli/... -count=1 $ go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml $ git diff --check -(output) +? git.toki-labs.com/toki/alt/apps/cli/cmd/alt [no test files] +ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.011s +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.180s +scenario=kis_daily_import_smoke status=valid steps=1 ``` --- @@ -157,3 +151,19 @@ $ git diff --check > **[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: `apps/cli/internal/operator/runner.go:196` creates the base `StepEvent` with only `Count: noCount`, while `apps/cli/internal/operator/output.go:111` and `apps/cli/internal/operator/output.go:152` now emit import counts whenever they are `>= 0`. Because `InstrumentCount` and `BarCount` default to `0`, every non-import step can leak `instrument_count=0` and `bar_count=0` into text/JSONL output. Fix by initializing import count fields to `noCount` on all base/transport `StepEvent` paths, or by making the writer gate import counts on an import-specific presence signal, and add negative assertions for hello/list/backtest output. + - Required: `apps/cli/internal/operator/scenario.go:386` only validates `from_yyyymmdd` / `to_yyyymmdd` when present, so `alt operator scenario validate` accepts an import scenario with no date range. The API forwards this through `validateImportDateRange` (`services/api/internal/socket/market.go:77`), and the worker explicitly requires both endpoints (`services/worker/internal/socket/market.go:125`). Fix CLI dry-run validation to reject missing `request.from_yyyymmdd` and `request.to_yyyymmdd`, and add table-driven validation cases for both missing fields. +- 다음 단계: FAIL이므로 user-review gate 없이 후속 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_1.log b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_1.log new file mode 100644 index 0000000..0f2420e --- /dev/null +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_1.log @@ -0,0 +1,146 @@ + + +# Code Review Reference - REVIEW_CLI + +> **[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/02+01_import_cli_scenario, plan=1, tag=REVIEW_CLI + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operator-surface/milestones/command-first-operator-workflow.md` +- Task ids: + - `live-import-command`: KIS live daily bars를 worker storage에 import하는 command 또는 YAML scenario가 있다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_N.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/02+01_import_cli_scenario/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_CLI-1] Import Count Output Leakage | [x] | +| [REVIEW_CLI-2] Import Date Dry-run Validation | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_CLI-1] Prevent import count fields from leaking into non-import step output. 검증: focused output tests prove non-import steps omit `instrument_count`/`bar_count` while import success still emits them. +- [x] [REVIEW_CLI-2] Require import date range in CLI dry-run validation to match API/worker contract. 검증: scenario validation tests reject missing `from_yyyymmdd` and `to_yyyymmdd`, and the import smoke fixture still validates. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_N.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-command-first-operator-workflow/02+01_import_cli_scenario/`를 `agent-task/archive/YYYY/MM/m-command-first-operator-workflow/02+01_import_cli_scenario/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-command-first-operator-workflow/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다. + +## 계획 대비 변경 사항 + +없음. + +## 주요 설계 결정 + +- non-import step event(예: hello, list, backtest) 및 transport error 상황에서 `instrument_count`와 `bar_count` 필드가 `0` 기본값으로 인쇄되어 출력 규칙이 깨지는 문제를 해결하기 위해, 모든 기본 및 예외 `StepEvent` 인스턴스 생성 시 두 필드를 명시적으로 `noCount`(-1)로 초기화해 출력되지 않도록 가드했습니다. +- CLI validation 단계와 실 구동(API/worker) 단계 사이의 날짜 필수 여부 불일치를 막기 위해 `scenario.go`의 dry-run validation에서 `from_yyyymmdd`와 `to_yyyymmdd` 둘 다 필수로 요구하도록 가드를 강화했습니다. 이에 맞추어 기존 `scenario_test.go` 테스트 셋의 `import_` 케이스들도 dates 필드를 명시하도록 업데이트하고, 누락 관련 validation 실패 케이스들을 추가했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Non-import text output and JSONL output must not include `instrument_count` or `bar_count`. +- Import success output must still include `provider`, `instrument_count`, and `bar_count`, including real zero counts if the response reports zero. +- `alt operator scenario validate` must fail before any socket call when `import_daily_bars` omits `from_yyyymmdd` or `to_yyyymmdd`. +- `kis_daily_import_smoke.yaml` remains valid and contains no raw KIS response, token, account, or secret values. +- Roadmap completion target is still only `live-import-command`. + +## 검증 결과 + +### REVIEW_CLI-1 중간 검증 +```bash +$ go test ./apps/cli/internal/operator -run 'RunScenario.*(API|Market|Backtest|Import)|JSONL' -count=1 +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.015s +``` + +### REVIEW_CLI-2 중간 검증 +```bash +$ go test ./apps/cli/internal/operator -run 'Scenario|ImportDailyBars' -count=1 +$ go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.024s +scenario=kis_daily_import_smoke status=valid steps=1 +``` + +### 최종 검증 +```bash +$ go test ./apps/cli/internal/operator -run 'RunScenario|ImportDailyBars|Scenario|Handoff|JSONL' -count=1 +$ go test ./apps/cli/... -count=1 +$ go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml +$ git diff --check +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.033s +? git.toki-labs.com/toki/alt/apps/cli/cmd/alt [no test files] +ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.020s +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.171s +scenario=kis_daily_import_smoke status=valid steps=1 +(exit code 0, no 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. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 `complete.log`를 작성하고 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/complete.log b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/complete.log new file mode 100644 index 0000000..1252c41 --- /dev/null +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/complete.log @@ -0,0 +1,46 @@ +# Complete - m-command-first-operator-workflow/02+01_import_cli_scenario + +## 완료 일시 + +2026-06-03 + +## 요약 + +Import CLI scenario task completed after 2 review loops; final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | Import count output leakage and missing import date dry-run validation required follow-up. | +| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | Follow-up fixed output count sentinels and required import date validation. | + +## 구현/정리 내용 + +- Added `import_daily_bars` CLI scenario schema, client method, runner execution, import output keys, fixtures, and handoff coverage. +- Repaired follow-up review findings by suppressing import count keys on non-import output and requiring `from_yyyymmdd` / `to_yyyymmdd` during CLI dry-run validation. + +## 최종 검증 + +- `go test ./apps/cli/internal/operator -run 'RunScenario.*(API|Market|Backtest|Import)|JSONL' -count=1` - PASS; `ok git.toki-labs.com/toki/alt/apps/cli/internal/operator`. +- `go test ./apps/cli/internal/operator -run 'Scenario|ImportDailyBars' -count=1` - PASS; `ok git.toki-labs.com/toki/alt/apps/cli/internal/operator`. +- `go test ./apps/cli/internal/operator -run 'RunScenario|ImportDailyBars|Scenario|Handoff|JSONL' -count=1` - PASS; `ok git.toki-labs.com/toki/alt/apps/cli/internal/operator`. +- `go test ./apps/cli/... -count=1` - PASS; cli command package had no tests, internal cli/operator packages passed. +- `go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml` - PASS; `scenario=kis_daily_import_smoke status=valid steps=1`. +- `git diff --check` - PASS; no output. +- Missing-date validation spot check - PASS; `import_daily_bars requires request.from_yyyymmdd` and process failure observed before socket use. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/operator-surface/milestones/command-first-operator-workflow.md` +- Completed task ids: + - `live-import-command`: PASS; evidence=`agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/plan_cloud_G07_1.log`, `agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_1.log`; verification=`go test ./apps/cli/... -count=1`, `go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml`, `git diff --check` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-command-first-operator-workflow/02+01_import_cli_scenario/PLAN-cloud-G07.md b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-command-first-operator-workflow/02+01_import_cli_scenario/PLAN-cloud-G07.md rename to agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/plan_cloud_G07_1.log b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/plan_cloud_G07_1.log new file mode 100644 index 0000000..0b0ccf6 --- /dev/null +++ b/agent-task/archive/2026/06/m-command-first-operator-workflow/02+01_import_cli_scenario/plan_cloud_G07_1.log @@ -0,0 +1,116 @@ + + +# Plan - Review Follow-up Import CLI Scenario + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 code-review FAIL 후속 구현 지시서다. 범위는 archived `code_review_cloud_G07_0.log`의 Required 두 건으로 제한한다. 구현 완료 전 active `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채운다. 최종 판정, archive 이동, `complete.log` 작성은 code-review-skill 전용이다. 사용자-only blocker가 있으면 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 후속 에이전트가 재실행으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +첫 리뷰에서 `import_daily_bars` CLI scenario 구현은 기본 경로 테스트를 통과했지만, 두 가지 계약 문제가 남았다. 새 import count 출력 필드가 non-import step output에 0 값으로 새어 나갈 수 있고, CLI dry-run validation이 API/worker가 요구하는 import date range보다 느슨하다. 이 follow-up은 output contract와 dry-run validation contract를 좁게 복구한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/operator-surface/milestones/command-first-operator-workflow.md` +- Task ids: + - `live-import-command`: KIS live daily bars를 worker storage에 import하는 command 또는 YAML scenario가 있다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 근거 파일 + +- `agent-task/m-command-first-operator-workflow/02+01_import_cli_scenario/code_review_cloud_G07_0.log` +- `apps/cli/internal/operator/runner.go` +- `apps/cli/internal/operator/output.go` +- `apps/cli/internal/operator/scenario.go` +- `apps/cli/internal/operator/scenario_test.go` +- `apps/cli/internal/operator/runner_market_test.go` +- `apps/cli/internal/operator/runner_error_test.go` +- `services/api/internal/socket/market.go` +- `services/worker/internal/socket/market.go` + +### 범위 결정 근거 + +- Include only the two Required findings from review 0. +- Do not change protobuf/API/worker contracts; the CLI must align with the existing API/worker import date contract. +- Do not add live KIS smoke or secret-dependent evidence. +- Do not touch Flutter UI or roadmap files. + +### 빌드 등급 + +- build=`cloud-G07`, review=`cloud-G07`: CLI scenario validation and stdout/JSONL output contract are the central behavior. + +## 구현 체크리스트 + +- [ ] [REVIEW_CLI-1] Prevent import count fields from leaking into non-import step output. 검증: focused output tests prove non-import steps omit `instrument_count`/`bar_count` while import success still emits them. +- [ ] [REVIEW_CLI-2] Require import date range in CLI dry-run validation to match API/worker contract. 검증: scenario validation tests reject missing `from_yyyymmdd` and `to_yyyymmdd`, and the import smoke fixture still validates. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [REVIEW_CLI-1] Import Count Output Leakage + +### 문제 + +`apps/cli/internal/operator/runner.go:196` initializes the base `StepEvent` with `Count: noCount` only. `apps/cli/internal/operator/output.go:111` and `apps/cli/internal/operator/output.go:152` emit `instrument_count` and `bar_count` whenever the values are `>= 0`. Because Go int fields default to `0`, hello/list/backtest/transport events can print import-specific zero counts. + +### 해결 방법 + +Initialize `InstrumentCount` and `BarCount` to `noCount` for every base event path before `WriteStep`, or introduce an equivalent explicit presence signal. Preserve legitimate import success output, including zero counts if the import response really reports zero instruments or bars. Add negative assertions for at least one non-import text output and one JSONL output path so this regression cannot pass by substring-only tests. + +### 수정 파일 및 체크리스트 + +- [ ] `apps/cli/internal/operator/runner.go`: base/transport `StepEvent` import count defaults. +- [ ] `apps/cli/internal/operator/runner_market_test.go` or `apps/cli/internal/operator/runner_error_test.go`: non-import output must not contain import count keys. +- [ ] `apps/cli/internal/operator/output.go`: only if the writer contract needs an explicit presence signal instead of sentinel initialization. + +### 테스트 작성 + +Add assertions that hello/list/backtest output omits `instrument_count` and `bar_count`; keep the import success test asserting `instrument_count=2` and `bar_count=100`. + +### 중간 검증 + +```bash +go test ./apps/cli/internal/operator -run 'RunScenario.*(API|Market|Backtest|Import)|JSONL' -count=1 +``` + +Expected: focused runner/output tests pass and prove non-import output has no import count keys. + +## [REVIEW_CLI-2] Import Date Dry-run Validation + +### 문제 + +`apps/cli/internal/operator/scenario.go:386` validates `from_yyyymmdd` and `to_yyyymmdd` only when present. The API import handler calls `validateImportDateRange` and the worker `parseImportDateRange` requires both endpoints, so `alt operator scenario validate` currently accepts an import scenario that the runtime later rejects as `invalid_request`. + +### 해결 방법 + +Make `import_daily_bars` dry-run validation require both `request.from_yyyymmdd` and `request.to_yyyymmdd`, then keep the existing `YYYYMMDD` shape and ascending range checks. Add table-driven cases for missing `from_yyyymmdd` and missing `to_yyyymmdd`. Keep `kis_daily_import_smoke.yaml` valid. + +### 수정 파일 및 체크리스트 + +- [ ] `apps/cli/internal/operator/scenario.go`: require both import date endpoints before shape/range validation. +- [ ] `apps/cli/internal/operator/scenario_test.go`: add missing-from and missing-to invalid cases. + +### 테스트 작성 + +Extend the existing invalid scenario validation table. The missing-date tests should fail during `validate`, before any socket call. + +### 중간 검증 + +```bash +go test ./apps/cli/internal/operator -run 'Scenario|ImportDailyBars' -count=1 +go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml +``` + +Expected: scenario tests pass and the import smoke fixture remains valid. + +## 최종 검증 + +```bash +go test ./apps/cli/internal/operator -run 'RunScenario|ImportDailyBars|Scenario|Handoff|JSONL' -count=1 +go test ./apps/cli/... -count=1 +go run ./apps/cli/cmd/alt operator scenario validate --file apps/cli/testdata/operator/kis_daily_import_smoke.yaml +git diff --check +``` + +Expected: all commands exit 0. Record actual stdout/stderr in `CODE_REVIEW-cloud-G07.md`. diff --git a/apps/cli/internal/operator/client.go b/apps/cli/internal/operator/client.go index 03893d6..54a138a 100644 --- a/apps/cli/internal/operator/client.go +++ b/apps/cli/internal/operator/client.go @@ -141,6 +141,11 @@ func (c *APIClient) CompareBacktestRuns(ctx context.Context, req *altv1.CompareB return sendTyped[*altv1.CompareBacktestRunsRequest, *altv1.CompareBacktestRunsResponse](c, ctx, req) } +// ImportDailyBars requests the import of KIS daily bars into the worker storage. +func (c *APIClient) 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 API call. It derives the // request timeout from the context deadline and maps a dropped connection to // ErrTransport so each method stays a one-line forwarder and the transport vs. diff --git a/apps/cli/internal/operator/client_test.go b/apps/cli/internal/operator/client_test.go index f9a677a..8960205 100644 --- a/apps/cli/internal/operator/client_test.go +++ b/apps/cli/internal/operator/client_test.go @@ -29,6 +29,7 @@ type fakeAPI struct { getBacktestRunDetailResp *altv1.GetBacktestRunDetailResponse getBacktestResultResp *altv1.GetBacktestResultResponse compareBacktestRunsResp *altv1.CompareBacktestRunsResponse + importDailyBarsResp *altv1.ImportDailyBarsResponse getBacktestRunFunc func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error) @@ -37,6 +38,7 @@ type fakeAPI struct { barsReq *altv1.ListBarsRequest startBacktestReq *altv1.StartBacktestRequest getBacktestRunReq *altv1.GetBacktestRunRequest + importDailyBarsReq *altv1.ImportDailyBarsRequest } func (f *fakeAPI) setInstReq(req *altv1.ListInstrumentsRequest) { @@ -87,6 +89,18 @@ func (f *fakeAPI) lastGetBacktestRunReq() *altv1.GetBacktestRunRequest { return f.getBacktestRunReq } +func (f *fakeAPI) setImportDailyBarsReq(req *altv1.ImportDailyBarsRequest) { + f.mu.Lock() + defer f.mu.Unlock() + f.importDailyBarsReq = req +} + +func (f *fakeAPI) lastImportDailyBarsReq() *altv1.ImportDailyBarsRequest { + f.mu.Lock() + defer f.mu.Unlock() + return f.importDailyBarsReq +} + // startFakeAPIServer starts a proto-socket server on a free localhost port that // answers hello/list_instruments/list_bars with the supplied canned responses // and records the received requests. It returns the ws:// URL and stops the @@ -168,6 +182,13 @@ func startFakeAPIServer(t *testing.T, api *fakeAPI) string { } return &altv1.CompareBacktestRunsResponse{}, nil }) + protoSocket.AddRequestListenerTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse](&c.Communicator, func(req *altv1.ImportDailyBarsRequest) (*altv1.ImportDailyBarsResponse, error) { + api.setImportDailyBarsReq(req) + if api.importDailyBarsResp != nil { + return api.importDailyBarsResp, nil + } + return &altv1.ImportDailyBarsResponse{}, nil + }) } if err := srv.Start(ctx); err != nil { @@ -302,3 +323,65 @@ func TestAPIClientConnectUnavailable(t *testing.T) { t.Errorf("error = %v, want it to wrap ErrTransport", err) } } + +func TestAPIClientImportDailyBars(t *testing.T) { + api := &fakeAPI{importDailyBarsResp: &altv1.ImportDailyBarsResponse{ + Provider: "kis", + InstrumentCount: 1, + BarCount: 42, + }} + url := startFakeAPIServer(t, api) + client := dialTestClient(t, url) + + req := &altv1.ImportDailyBarsRequest{ + Provider: "kis", + SelectorKind: "watchlist", + Market: altv1.Market_MARKET_KR, + Venue: altv1.Venue_VENUE_KRX, + Symbols: []string{"005930"}, + FromYyyymmdd: "20240527", + ToYyyymmdd: "20240528", + } + resp, err := client.ImportDailyBars(context.Background(), req) + if err != nil { + t.Fatalf("ImportDailyBars: %v", err) + } + if resp.GetError() != nil { + t.Fatalf("unexpected typed error: %+v", resp.GetError()) + } + if resp.GetProvider() != "kis" { + t.Errorf("resp provider = %q, want kis", resp.GetProvider()) + } + if resp.GetInstrumentCount() != 1 { + t.Errorf("resp instrument count = %d, want 1", resp.GetInstrumentCount()) + } + if resp.GetBarCount() != 42 { + t.Errorf("resp bar count = %d, want 42", resp.GetBarCount()) + } + + got := api.lastImportDailyBarsReq() + if got == nil { + t.Fatal("server did not receive an import daily bars request") + } + if got.GetProvider() != "kis" { + t.Errorf("server received provider = %q, want kis", got.GetProvider()) + } + if got.GetSelectorKind() != "watchlist" { + t.Errorf("server received selector_kind = %q, want watchlist", got.GetSelectorKind()) + } + if got.GetMarket() != altv1.Market_MARKET_KR { + t.Errorf("server received market = %v, want MARKET_KR", got.GetMarket()) + } + if got.GetVenue() != altv1.Venue_VENUE_KRX { + t.Errorf("server received venue = %v, want VENUE_KRX", got.GetVenue()) + } + if len(got.GetSymbols()) != 1 || got.GetSymbols()[0] != "005930" { + t.Errorf("server received symbols = %v, want [005930]", got.GetSymbols()) + } + if got.GetFromYyyymmdd() != "20240527" { + t.Errorf("server received from_yyyymmdd = %q, want 20240527", got.GetFromYyyymmdd()) + } + if got.GetToYyyymmdd() != "20240528" { + t.Errorf("server received to_yyyymmdd = %q, want 20240528", got.GetToYyyymmdd()) + } +} diff --git a/apps/cli/internal/operator/handoff_test.go b/apps/cli/internal/operator/handoff_test.go index 4fc2789..87ff111 100644 --- a/apps/cli/internal/operator/handoff_test.go +++ b/apps/cli/internal/operator/handoff_test.go @@ -20,6 +20,7 @@ var requiredScenarios = []string{ "backtest_run_polling", "backtest_result_summary", "invalid_request_matrix", + "kis_daily_import_smoke", } func handoffMatrixPath() string { diff --git a/apps/cli/internal/operator/output.go b/apps/cli/internal/operator/output.go index 8921435..1da0e08 100644 --- a/apps/cli/internal/operator/output.go +++ b/apps/cli/internal/operator/output.go @@ -37,6 +37,11 @@ type StepEvent struct { EndingEquity string TotalReturn string TradeCount int + + // Import-specific output fields + Provider string + InstrumentCount int + BarCount int } // RunSummary is the final record describing a whole scenario run. @@ -100,6 +105,15 @@ func (o *Writer) WriteStep(ev StepEvent) { if ev.TradeCount > 0 { fields["trade_count"] = ev.TradeCount } + if ev.Provider != "" { + fields["provider"] = ev.Provider + } + if ev.InstrumentCount >= 0 { + fields["instrument_count"] = ev.InstrumentCount + } + if ev.BarCount >= 0 { + fields["bar_count"] = ev.BarCount + } o.writeJSON(fields) return } @@ -132,6 +146,15 @@ func (o *Writer) WriteStep(ev StepEvent) { if ev.TradeCount > 0 { line += fmt.Sprintf(" trade_count=%d", ev.TradeCount) } + if ev.Provider != "" { + line += fmt.Sprintf(" provider=%s", ev.Provider) + } + if ev.InstrumentCount >= 0 { + line += fmt.Sprintf(" instrument_count=%d", ev.InstrumentCount) + } + if ev.BarCount >= 0 { + line += fmt.Sprintf(" bar_count=%d", ev.BarCount) + } fmt.Fprintln(o.w, line) } diff --git a/apps/cli/internal/operator/runner.go b/apps/cli/internal/operator/runner.go index 9437683..a1fa4c2 100644 --- a/apps/cli/internal/operator/runner.go +++ b/apps/cli/internal/operator/runner.go @@ -59,6 +59,13 @@ var timeframeByName = map[string]altv1.Timeframe{ "minute_5": altv1.Timeframe_TIMEFRAME_MINUTE_5, } +// venueByName maps scenario venue strings onto the contract enum. +var venueByName = map[string]altv1.Venue{ + "": altv1.Venue_VENUE_UNSPECIFIED, + "unspecified": altv1.Venue_VENUE_UNSPECIFIED, + "krx": altv1.Venue_VENUE_KRX, +} + // RunOptions configures a scenario run. type RunOptions struct { // APIURL is the ws/wss endpoint of the API control plane. @@ -90,12 +97,14 @@ func RunScenario(ctx context.Context, sc *Scenario, opts RunOptions, stdout io.W cancelDial() if err != nil { out.WriteStep(StepEvent{ - Scenario: sc.Name, - Step: firstStepID(sc), - Action: firstStepAction(sc), - Status: statusTransportError, - Count: noCount, - ErrorMessage: err.Error(), + Scenario: sc.Name, + Step: firstStepID(sc), + Action: firstStepAction(sc), + Status: statusTransportError, + Count: noCount, + InstrumentCount: noCount, + BarCount: noCount, + ErrorMessage: err.Error(), }) out.WriteSummary(RunSummary{Scenario: sc.Name, Status: summaryTransport, Steps: len(sc.Steps), Passed: 0, ExitCode: codeTransport}) return codeTransport @@ -186,7 +195,14 @@ func runStep(ctx context.Context, client *APIClient, scenario string, step Step, stepCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - ev := StepEvent{Scenario: scenario, Step: step.ID, Action: string(step.Action), Count: noCount} + ev := StepEvent{ + Scenario: scenario, + Step: step.ID, + Action: string(step.Action), + Count: noCount, + InstrumentCount: noCount, + BarCount: noCount, + } switch step.Action { case ActionHello: @@ -211,6 +227,23 @@ func runStep(ctx context.Context, client *APIClient, scenario string, step Step, ev2, code := evaluateMarket(ev, step, resp.GetError(), len(resp.GetInstruments())) return ev2, code, "" + case ActionImportDailyBars: + resp, err := client.ImportDailyBars(stepCtx, &altv1.ImportDailyBarsRequest{ + Provider: step.Request.Provider, + SelectorKind: step.Request.SelectorKind, + Market: marketByName[step.Request.Market], + Venue: venueByName[step.Request.Venue], + Name: step.Request.Name, + Symbols: step.Request.Symbols, + FromYyyymmdd: step.Request.FromYYYYMMDD, + ToYyyymmdd: step.Request.ToYYYYMMDD, + }) + if err != nil { + return transportEvent3(ev, err) + } + ev2, code := evaluateImport(ev, step, resp) + return ev2, code, "" + case ActionListBars: resp, err := client.ListBars(stepCtx, &altv1.ListBarsRequest{ InstrumentId: step.Request.InstrumentID, @@ -430,6 +463,49 @@ func evaluateMarket(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, count int return ev, codeOK } +// evaluateImport checks an import response. +func evaluateImport(ev StepEvent, step Step, resp *altv1.ImportDailyBarsResponse) (StepEvent, int) { + expectsError := step.Expect.Status == statusError + errInfo := resp.GetError() + + // Default counts to noCount (-1) so they don't print for error steps unless set. + ev.InstrumentCount = noCount + ev.BarCount = noCount + + if errInfo != nil { + ev.ErrorCode = errInfo.GetCode() + ev.ErrorMessage = errInfo.GetMessage() + if !expectsError { + ev.Status = statusError + return ev, codeAppError + } + if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() { + ev.Status = statusMismatch + return ev, codeAppError + } + // Expected typed error arrived: the step passed. + ev.Status = statusOK + return ev, codeOK + } + + ev.Provider = resp.GetProvider() + ev.InstrumentCount = int(resp.GetInstrumentCount()) + ev.BarCount = int(resp.GetBarCount()) + + if expectsError { + ev.Status = statusMismatch + ev.ErrorMessage = "expected a typed error but the request succeeded" + return ev, codeAppError + } + if step.Expect.MinCount != nil && ev.BarCount < *step.Expect.MinCount { + ev.Status = statusMismatch + ev.ErrorMessage = fmt.Sprintf("expected at least %d results, got %d", *step.Expect.MinCount, ev.BarCount) + return ev, codeAppError + } + ev.Status = statusOK + return ev, codeOK +} + // evaluateBacktest checks a backtest response against expectations. func evaluateBacktest(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, run *altv1.BacktestRun, result *altv1.BacktestResult, count int) (StepEvent, int, string) { expectsError := step.Expect.Status == statusError diff --git a/apps/cli/internal/operator/runner_error_test.go b/apps/cli/internal/operator/runner_error_test.go index 5389941..5f385ed 100644 --- a/apps/cli/internal/operator/runner_error_test.go +++ b/apps/cli/internal/operator/runner_error_test.go @@ -160,6 +160,12 @@ func TestJSONLSummaryIncludesExitCode(t *testing.T) { if !strings.Contains(out, `"exit_code":0`) { t.Errorf("output %q missing \"exit_code\":0 in summary", out) } + if strings.Contains(out, "instrument_count") { + t.Errorf("output %q should not contain instrument_count for hello action", out) + } + if strings.Contains(out, "bar_count") { + t.Errorf("output %q should not contain bar_count for hello action", out) + } } // Failure path (transport failure) diff --git a/apps/cli/internal/operator/runner_market_test.go b/apps/cli/internal/operator/runner_market_test.go index 0853d43..33952f2 100644 --- a/apps/cli/internal/operator/runner_market_test.go +++ b/apps/cli/internal/operator/runner_market_test.go @@ -35,6 +35,12 @@ func TestRunScenarioAPISmoke(t *testing.T) { if !strings.Contains(out, "status=ok") { t.Errorf("output %q missing status=ok", out) } + if strings.Contains(out, "instrument_count") { + t.Errorf("output %q should not contain instrument_count for hello action", out) + } + if strings.Contains(out, "bar_count") { + t.Errorf("output %q should not contain bar_count for hello action", out) + } } func TestRunScenarioMarketDataStatus(t *testing.T) { @@ -177,6 +183,114 @@ func TestRunScenarioTransportFailure(t *testing.T) { } } +func TestRunScenarioImportDailyBarsSuccess(t *testing.T) { + url := startFakeAPIServer(t, &fakeAPI{ + importDailyBarsResp: &altv1.ImportDailyBarsResponse{ + Provider: "kis", + InstrumentCount: 2, + BarCount: 100, + }, + }) + sc := &Scenario{ + Name: "import_daily_bars_success", + Steps: []Step{{ + ID: "import1", + Action: ActionImportDailyBars, + Request: Request{ + Provider: "kis", + SelectorKind: "watchlist", + Market: "kr", + Venue: "krx", + Symbols: []string{"005930", "000660"}, + }, + Expect: Expect{ + Status: "ok", + }, + }}, + } + + out, code := runScenario(t, sc, url) + if code != codeOK { + t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) + } + for _, want := range []string{ + "scenario=import_daily_bars_success", + "step=import1", + "action=import_daily_bars", + "status=ok", + "provider=kis", + "instrument_count=2", + "bar_count=100", + } { + if !strings.Contains(out, want) { + t.Errorf("output %q missing %q", out, want) + } + } +} + +func TestRunScenarioImportDailyBarsExpectedTypedError(t *testing.T) { + url := startFakeAPIServer(t, &fakeAPI{ + importDailyBarsResp: &altv1.ImportDailyBarsResponse{ + Error: &altv1.ErrorInfo{Code: "unavailable", Message: "worker offline"}, + }, + }) + sc := &Scenario{ + Name: "import_expected_error", + Steps: []Step{{ + ID: "import1", + Action: ActionImportDailyBars, + Request: Request{ + Provider: "kis", + SelectorKind: "watchlist", + Symbols: []string{"005930"}, + }, + Expect: Expect{ + Status: "error", + ErrorCode: "unavailable", + }, + }}, + } + + out, code := runScenario(t, sc, url) + if code != codeOK { + t.Fatalf("exit code = %d, want 0 (out=%q)", code, out) + } + if !strings.Contains(out, "error.code=unavailable") { + t.Errorf("output %q missing error.code=unavailable", out) + } +} + +func TestRunScenarioImportDailyBarsUnexpectedTypedError(t *testing.T) { + url := startFakeAPIServer(t, &fakeAPI{ + importDailyBarsResp: &altv1.ImportDailyBarsResponse{ + Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "bad date format"}, + }, + }) + sc := &Scenario{ + Name: "import_unexpected_error", + Steps: []Step{{ + ID: "import1", + Action: ActionImportDailyBars, + Request: Request{ + Provider: "kis", + SelectorKind: "watchlist", + Symbols: []string{"005930"}, + }, + Expect: Expect{ + Status: "ok", + }, + }}, + } + + out, code := runScenario(t, sc, url) + if code != codeAppError { + t.Fatalf("exit code = %d, want 1 (out=%q)", code, out) + } + if !strings.Contains(out, "status=error") || !strings.Contains(out, "error.code=invalid_request") { + t.Errorf("output %q missing error status or invalid_request code", out) + } +} + func intPtr(n int) *int { return &n } // itoa avoids importing strconv just for one conversion in tests. diff --git a/apps/cli/internal/operator/scenario.go b/apps/cli/internal/operator/scenario.go index 7cdb744..3c1df68 100644 --- a/apps/cli/internal/operator/scenario.go +++ b/apps/cli/internal/operator/scenario.go @@ -23,6 +23,8 @@ type Action string const ( // ActionHello validates a basic API connection handshake. ActionHello Action = "hello" + // ActionImportDailyBars imports market data daily bars. + ActionImportDailyBars Action = "import_daily_bars" // ActionListInstruments lists market instruments through the API. ActionListInstruments Action = "list_instruments" // ActionListBars lists market bars for an instrument through the API. @@ -44,6 +46,7 @@ const ( // validActions is the closed set used for strict dry-run validation. var validActions = map[Action]bool{ ActionHello: true, + ActionImportDailyBars: true, ActionListInstruments: true, ActionListBars: true, ActionStartBacktest: true, @@ -64,6 +67,23 @@ var validMarkets = map[string]bool{ "us": true, } +// validProviders is the set of data providers the import_daily_bars request may use. +var validProviders = map[string]bool{ + "kis": true, +} + +// validSelectorKinds is the set of selector kinds the import_daily_bars request may use. +var validSelectorKinds = map[string]bool{ + "watchlist": true, +} + +// validVenues is the set of venues the import_daily_bars request may use. +var validVenues = map[string]bool{ + "": true, + "unspecified": true, + "krx": true, +} + // validTimeframes is the set of bar timeframe strings a list_bars request may // use, mirroring the API's accepted Timeframe values. var validTimeframes = map[string]bool{ @@ -153,6 +173,20 @@ type Request struct { FromUnixMs int64 `yaml:"from_unix_ms"` ToUnixMs int64 `yaml:"to_unix_ms"` + // Provider is the data provider (e.g. "kis"). + Provider string `yaml:"provider"` + // SelectorKind selects the instrument list source (e.g. "watchlist"). + SelectorKind string `yaml:"selector_kind"` + // Venue specifies the exchange/market venue (e.g. "krx", "unspecified"). + Venue string `yaml:"venue"` + // Name is the name of the selection list, if applicable. + Name string `yaml:"name"` + // Symbols is the explicit list of tickers/symbols to import. + Symbols []string `yaml:"symbols"` + // FromYYYYMMDD and ToYYYYMMDD bound the import date window. + FromYYYYMMDD string `yaml:"from_yyyymmdd"` + ToYYYYMMDD string `yaml:"to_yyyymmdd"` + // StrategyID selects the strategy for starting a backtest. StrategyID string `yaml:"strategy_id"` // RunID identifies a specific backtest run. @@ -322,6 +356,56 @@ func validateRequest(step Step) error { if step.Request.Status != "" && !validBacktestStatuses[step.Request.Status] { return fmt.Errorf("step %q: unsupported status %q", step.ID, step.Request.Status) } + case ActionImportDailyBars: + if step.Request.Provider == "" { + return fmt.Errorf("step %q: import_daily_bars requires request.provider", step.ID) + } + if !validProviders[step.Request.Provider] { + return fmt.Errorf("step %q: unsupported provider %q", step.ID, step.Request.Provider) + } + if step.Request.SelectorKind == "" { + return fmt.Errorf("step %q: import_daily_bars requires request.selector_kind", step.ID) + } + if !validSelectorKinds[step.Request.SelectorKind] { + return fmt.Errorf("step %q: unsupported selector_kind %q", step.ID, step.Request.SelectorKind) + } + if len(step.Request.Symbols) == 0 { + return fmt.Errorf("step %q: import_daily_bars requires request.symbols", step.ID) + } + for _, sym := range step.Request.Symbols { + if strings.TrimSpace(sym) == "" { + return fmt.Errorf("step %q: import_daily_bars request.symbols cannot contain empty or blank values", step.ID) + } + } + if !validMarkets[step.Request.Market] { + return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market) + } + if step.Request.Venue != "" && !validVenues[step.Request.Venue] { + return fmt.Errorf("step %q: unsupported venue %q", step.ID, step.Request.Venue) + } + if step.Request.FromYYYYMMDD == "" { + return fmt.Errorf("step %q: import_daily_bars requires request.from_yyyymmdd", step.ID) + } + if !validateYYYYMMDD(step.Request.FromYYYYMMDD) { + return fmt.Errorf("step %q: request.from_yyyymmdd must be in YYYYMMDD format", step.ID) + } + if step.Request.ToYYYYMMDD == "" { + return fmt.Errorf("step %q: import_daily_bars requires request.to_yyyymmdd", step.ID) + } + if !validateYYYYMMDD(step.Request.ToYYYYMMDD) { + return fmt.Errorf("step %q: request.to_yyyymmdd must be in YYYYMMDD format", step.ID) + } + if step.Request.FromYYYYMMDD > step.Request.ToYYYYMMDD { + return fmt.Errorf("step %q: request.from_yyyymmdd cannot be after request.to_yyyymmdd", step.ID) + } } return nil } + +func validateYYYYMMDD(s string) bool { + if len(s) != 8 { + return false + } + _, err := time.Parse("20060102", s) + return err == nil +} diff --git a/apps/cli/internal/operator/scenario_test.go b/apps/cli/internal/operator/scenario_test.go index 5868fe5..05519c7 100644 --- a/apps/cli/internal/operator/scenario_test.go +++ b/apps/cli/internal/operator/scenario_test.go @@ -179,6 +179,224 @@ steps: `, expectedErr: "cannot be after request.to_unix_ms", }, + { + name: "import_missing_provider", + yaml: ` +name: import_missing_provider +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + selector_kind: watchlist + symbols: ["005930"] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "requires request.provider", + }, + { + name: "import_invalid_provider", + yaml: ` +name: import_invalid_provider +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: invalid + selector_kind: watchlist + symbols: ["005930"] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "unsupported provider", + }, + { + name: "import_missing_selector_kind", + yaml: ` +name: import_missing_selector_kind +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + symbols: ["005930"] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "requires request.selector_kind", + }, + { + name: "import_invalid_selector_kind", + yaml: ` +name: import_invalid_selector_kind +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: invalid + symbols: ["005930"] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "unsupported selector_kind", + }, + { + name: "import_missing_symbols", + yaml: ` +name: import_missing_symbols +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "requires request.symbols", + }, + { + name: "import_empty_symbol", + yaml: ` +name: import_empty_symbol +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930", " "] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "symbols cannot contain empty or blank values", + }, + { + name: "import_invalid_market", + yaml: ` +name: import_invalid_market +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930"] + market: invalid + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "unsupported market", + }, + { + name: "import_invalid_venue", + yaml: ` +name: import_invalid_venue +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930"] + venue: invalid + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" +`, + expectedErr: "unsupported venue", + }, + { + name: "import_invalid_from_yyyymmdd", + yaml: ` +name: import_invalid_from_yyyymmdd +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930"] + from_yyyymmdd: "2024-05" + to_yyyymmdd: "20240528" +`, + expectedErr: "must be in YYYYMMDD format", + }, + { + name: "import_invalid_to_yyyymmdd", + yaml: ` +name: import_invalid_to_yyyymmdd +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930"] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240532" +`, + expectedErr: "must be in YYYYMMDD format", + }, + { + name: "import_reversed_dates", + yaml: ` +name: import_reversed_dates +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930"] + from_yyyymmdd: "20240528" + to_yyyymmdd: "20240527" +`, + expectedErr: "cannot be after request.to_yyyymmdd", + }, + { + name: "import_missing_from_yyyymmdd", + yaml: ` +name: import_missing_from_yyyymmdd +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930"] + to_yyyymmdd: "20240528" +`, + expectedErr: "requires request.from_yyyymmdd", + }, + { + name: "import_missing_to_yyyymmdd", + yaml: ` +name: import_missing_to_yyyymmdd +timeout: 1s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + symbols: ["005930"] + from_yyyymmdd: "20240527" +`, + expectedErr: "requires request.to_yyyymmdd", + }, } for _, tt := range tests { @@ -193,3 +411,55 @@ steps: }) } } + +func TestValidateImportDailyBars(t *testing.T) { + yamlText := ` +name: valid_import +timeout: 5s +steps: + - id: step1 + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + market: kr + venue: krx + symbols: ["005930"] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" + expect: + status: ok +` + sc, err := ParseScenario([]byte(yamlText)) + if err != nil { + t.Fatalf("unexpected error parsing valid import scenario: %v", err) + } + if len(sc.Steps) != 1 { + t.Fatalf("steps = %d, want 1", len(sc.Steps)) + } + step := sc.Steps[0] + if step.Action != ActionImportDailyBars { + t.Errorf("action = %q, want %q", step.Action, ActionImportDailyBars) + } + if step.Request.Provider != "kis" { + t.Errorf("provider = %q, want kis", step.Request.Provider) + } + if step.Request.SelectorKind != "watchlist" { + t.Errorf("selector_kind = %q, want watchlist", step.Request.SelectorKind) + } + if step.Request.Market != "kr" { + t.Errorf("market = %q, want kr", step.Request.Market) + } + if step.Request.Venue != "krx" { + t.Errorf("venue = %q, want krx", step.Request.Venue) + } + if len(step.Request.Symbols) != 1 || step.Request.Symbols[0] != "005930" { + t.Errorf("symbols = %v, want [005930]", step.Request.Symbols) + } + if step.Request.FromYYYYMMDD != "20240527" { + t.Errorf("from_yyyymmdd = %q, want 20240527", step.Request.FromYYYYMMDD) + } + if step.Request.ToYYYYMMDD != "20240528" { + t.Errorf("to_yyyymmdd = %q, want 20240528", step.Request.ToYYYYMMDD) + } +} diff --git a/apps/cli/testdata/operator/expected/kis_daily_import_smoke.jsonl b/apps/cli/testdata/operator/expected/kis_daily_import_smoke.jsonl new file mode 100644 index 0000000..72a568c --- /dev/null +++ b/apps/cli/testdata/operator/expected/kis_daily_import_smoke.jsonl @@ -0,0 +1,2 @@ +{"action":"import_daily_bars","bar_count":42,"instrument_count":1,"provider":"kis","scenario":"kis_daily_import_smoke","status":"ok","step":"import_samsung","type":"step"} +{"exit_code":0,"passed":1,"scenario":"kis_daily_import_smoke","status":"ok","steps":1,"type":"summary"} diff --git a/apps/cli/testdata/operator/headless_validation.md b/apps/cli/testdata/operator/headless_validation.md index c7107d1..1965571 100644 --- a/apps/cli/testdata/operator/headless_validation.md +++ b/apps/cli/testdata/operator/headless_validation.md @@ -31,6 +31,7 @@ ALT 운영 UI 게이트(`agent-ops/rules/project/rules.md`의 "운영 UI 구현 | `backtest_run_polling` | `alt operator scenario run --file testdata/operator/backtest_run_polling.yaml --api-url ws://127.0.0.1:8080/socket --output jsonl` | `testdata/operator/backtest_run_polling.yaml` | `testdata/operator/expected/backtest_run_polling.jsonl` | `0` (terminal 도달), `3` (polling timeout) | `GetBacktestRunResponse.run.status` 전이(pending→running→succeeded/failed/canceled) | run 진행 상태/전이 표시(진행바, 상태 칩) 화면 미정 | | `backtest_result_summary` | `alt operator scenario run --file testdata/operator/backtest_result_summary.yaml --api-url ws://127.0.0.1:8080/socket --output jsonl` | `testdata/operator/backtest_result_summary.yaml` | `testdata/operator/expected/backtest_result_summary.jsonl` | `0` (성공), `1` (missing run/typed error) | `GetBacktestResultResponse.result.summary` (`starting_cash`, `ending_equity`, `total_return`, `trade_count`) | result summary metrics / chart 시각화 화면 미정 | | `invalid_request_matrix` | `alt operator scenario run --file testdata/operator/invalid_request_matrix.yaml --api-url ws://127.0.0.1:8080/socket --output jsonl` | `testdata/operator/invalid_request_matrix.yaml` | `testdata/operator/expected/invalid_request_matrix.jsonl` | `0` (기대한 typed error 도달), `2` (malformed scenario는 `testdata/operator/malformed_scenario.yaml`로 validate 시 parse error) | `ErrorInfo.code` (`invalid_request`, `not_found`) | error/validation 메시지 노출 및 비정상 입력 가드 화면 미정 | +| `kis_daily_import_smoke` | `alt operator scenario run --file testdata/operator/kis_daily_import_smoke.yaml --api-url ws://127.0.0.1:8080/socket --output jsonl` | `testdata/operator/kis_daily_import_smoke.yaml` | `testdata/operator/expected/kis_daily_import_smoke.jsonl` | `0` (성공), `1` (mismatch/typed error) | `ImportDailyBarsResponse.provider`, `ImportDailyBarsResponse.instrument_count`, `ImportDailyBarsResponse.bar_count` | KIS 데이터 수집 / 진행 및 수집 결과(종목수, 바개수) 시각화 미정 | ## Exit code 계약 보강 노트 diff --git a/apps/cli/testdata/operator/kis_daily_import_smoke.yaml b/apps/cli/testdata/operator/kis_daily_import_smoke.yaml new file mode 100644 index 0000000..c13af9b --- /dev/null +++ b/apps/cli/testdata/operator/kis_daily_import_smoke.yaml @@ -0,0 +1,15 @@ +name: kis_daily_import_smoke +timeout: 5s +steps: + - id: import_samsung + action: import_daily_bars + request: + provider: kis + selector_kind: watchlist + market: kr + venue: krx + symbols: ["005930"] + from_yyyymmdd: "20240527" + to_yyyymmdd: "20240528" + expect: + status: ok