diff --git a/agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/CODE_REVIEW-cloud-G06.md b/agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/CODE_REVIEW-cloud-G06.md deleted file mode 100644 index bbe1cf4..0000000 --- a/agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/CODE_REVIEW-cloud-G06.md +++ /dev/null @@ -1,150 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> The task is NOT complete until every implementation-owned section below is filled in. -> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. -> Fill implementation-owned sections, then stop with active files in place and report ready for review. -> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. -> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. -> 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-14 -task=m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow, plan=0, tag=API - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md` -- Task ids: - - `input-selector`: 저장된 정제 데이터에서 종목군, 기간, frequency를 선택해 backtest run input으로 넘기는 경계를 만든다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-cloud-G06.md` -> `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` -> `plan_cloud_G06_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. -4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. -5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Async Run Payload Selector Preservation | [ ] | -| [API-2] Operator Start Backtest Selector | [ ] | -| [API-3] Headless Fixtures And Handoff Evidence | [ ] | - -## 구현 체크리스트 - -- [ ] `RunBacktestPayload`가 selector를 encode/decode하고 handler가 실행 spec에 복원하도록 구현한다. -- [ ] operator `start_backtest`가 universe/symbol/instrument_ids selector를 검증하고 `BacktestRunSpec.selector`로 전송하도록 구현한다. -- [ ] headless backtest fixtures와 handoff 문서가 import universe -> start selector -> poll/result 흐름을 증명하도록 갱신한다. -- [ ] 지정 `go test -count=1` 검증을 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/`를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-backtest-data-collection-infrastructure/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.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 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- selector가 starter payload와 job handler spec 재구성 과정에서 손실되지 않는지 확인한다. -- `start_backtest` selector requirement가 paper trading이나 non-backtest actions에 번지지 않았는지 확인한다. -- headless fixtures가 import와 start 모두 같은 universe를 참조하고 expected JSONL 변경이 불필요한지 확인한다. -- PASS 시 Roadmap Targets의 `input-selector`만 완료 이벤트 대상인지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. -- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. - -### API-1 중간 검증 -```bash -$ go test -count=1 ./services/worker/internal/jobs ./services/worker/internal/backtest -(output) -``` - -### API-2 중간 검증 -```bash -$ go test -count=1 ./apps/cli/internal/operator -(output) -``` - -### API-3 중간 검증 -```bash -$ go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli -(output) -``` - -### 최종 검증 -```bash -$ go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs ./services/worker/internal/socket ./services/api/internal/socket ./services/api/internal/workerclient ./apps/cli/internal/operator ./apps/cli/internal/cli -(output) -``` - ---- - -## Ownership - -| 섹션 | 소유자 | -|------|--------| -| 구현 항목별 완료 여부 | Implementing agent | -| 구현 체크리스트 | Implementing agent | -| 계획 대비 변경 사항 | Implementing agent | -| 주요 설계 결정 | Implementing agent | -| 사용자 리뷰 요청 | Implementing agent | -| 검증 결과 | Implementing agent | -| 코드리뷰 전용 체크리스트 | Review agent | -| 코드리뷰 결과 | Review agent | diff --git a/agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/PLAN-cloud-G06.md b/agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/PLAN-cloud-G06.md deleted file mode 100644 index 73c67b0..0000000 --- a/agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/PLAN-cloud-G06.md +++ /dev/null @@ -1,275 +0,0 @@ - -# PLAN-cloud-G06: Backtest Selector Flow - -## 이 파일을 읽는 구현 에이전트에게 -이 계획은 `01_input_selector_contract`와 `02+01_storage_bar_selector`가 PASS되어 각각 `complete.log`를 만든 뒤에만 구현한다. 구현 후 반드시 검증을 실행하고 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 stdout/stderr를 채운 뒤 active 파일을 유지한 채 리뷰 준비를 보고한다. 최종 PASS, log rename, `complete.log`, archive 이동은 code-review skill 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret, 또는 범위 충돌 때문에 멈춰야 하면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청`에 증거를 채운 뒤 중단한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청 사유가 아니다. - -## 배경 -선행 subtasks가 selector 계약과 storage filtering을 만들더라도 async job payload와 operator scenario가 selector를 잃으면 실제 backtest run input까지 이어지지 않는다. 이 subtask는 start command, async run job, operator named universe, headless handoff fixture를 연결한다. PASS되면 현재 마일스톤의 `backtest-ready/input-selector` 기능 Task를 완료할 수 있다. - -## 사용자 리뷰 요청 흐름 -구현 중 사용자 전용 결정이나 범위 충돌이 생기면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식 그대로 기록한다. 구현 에이전트는 채팅으로 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md` -- Task ids: - - `input-selector`: 저장된 정제 데이터에서 종목군, 기간, frequency를 선택해 backtest run input으로 넘기는 경계를 만든다. -- Completion mode: check-on-pass - -## 분석 결과 -### 읽은 파일 -- `agent-ops/rules/project/rules.md` -- `agent-ops/rules/private/rules.md` -- `agent-ops/rules/common/rules-roadmap.md` -- `agent-ops/skills/common/router.md` -- `agent-ops/skills/common/plan/SKILL.md` -- `agent-ops/skills/common/update-roadmap/SKILL.md` -- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` -- `agent-test/local/rules.md` -- `agent-test/local/operations-smoke.md` -- `agent-test/local/worker-smoke.md` -- `agent-test/local/contracts-smoke.md` -- `agent-test/local/domain-model-smoke.md` -- `agent-test/local/api-smoke.md` -- `agent-ops/rules/project/domain/operations/rules.md` -- `agent-ops/rules/project/domain/worker/rules.md` -- `agent-ops/rules/project/domain/contracts/rules.md` -- `agent-ops/rules/project/domain/domain-model/rules.md` -- `agent-ops/rules/project/domain/api/rules.md` -- `agent-roadmap/current.md` -- `agent-roadmap/phase/backtest-loop/PHASE.md` -- `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md` -- `packages/domain/backtest/types.go` -- `services/worker/internal/jobs/backtest_starter.go` -- `services/worker/internal/jobs/backtest_starter_test.go` -- `services/worker/internal/jobs/backtest_jobs.go` -- `services/worker/internal/jobs/backtest_jobs_test.go` -- `services/worker/internal/backtest/flow_test.go` -- `apps/cli/internal/operator/scenario.go` -- `apps/cli/internal/operator/scenario_test.go` -- `apps/cli/internal/operator/runner.go` -- `apps/cli/internal/operator/runner_backtest_test.go` -- `apps/cli/internal/operator/client.go` -- `apps/cli/internal/operator/client_test.go` -- `apps/cli/internal/operator/handoff_test.go` -- `apps/cli/testdata/operator/backtest_run_request.yaml` -- `apps/cli/testdata/operator/backtest_result_summary.yaml` -- `apps/cli/testdata/operator/backtest_run_polling.yaml` -- `apps/cli/testdata/operator/invalid_request_matrix.yaml` -- `apps/cli/testdata/operator/headless_validation.md` - -### 테스트 환경 규칙 -- `test_env=local`로 판단했다. -- `agent-test/local/rules.md`가 존재해 읽었다. -- 매칭 profile로 `operations-smoke.md`, `worker-smoke.md`, `api-smoke.md`, `contracts-smoke.md`, `domain-model-smoke.md`를 읽었다. -- operations/headless 변경이므로 CLI operator tests와 fixture handoff tests를 포함한다. -- worker async job payload 변경이므로 worker jobs/backtest tests를 포함한다. -- `go test -count=1` fresh 실행만 허용하고 test cache 출력은 허용하지 않는다. -- 현재 기준으로 최종 검증 후보 `go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs ./services/worker/internal/socket ./services/api/internal/socket ./services/api/internal/workerclient ./apps/cli/internal/operator ./apps/cli/internal/cli`가 exit 0임을 확인했다. - -### 테스트 커버리지 공백 -- `RunBacktestPayload`는 strategy/market/timeframe/date만 담고 selector를 보존하지 않는다. -- `RegisterRunBacktestHandler`는 payload에서 spec을 재구성하며 기존 run spec을 덮어쓰므로 selector가 사라질 수 있다. -- operator `start_backtest`는 named universe를 지원하지 않고 selector를 `BacktestRunSpec`에 넣지 않는다. -- headless fixtures는 import 후 start가 같은 universe를 backtest input selector로 넘기는지 문서화하지 않는다. - -### 심볼 참조 -- renamed/removed symbol: none. -- `RunBacktestPayload` references: `services/worker/internal/jobs/backtest_starter.go:80`, `services/worker/internal/jobs/backtest_jobs.go:20`, `services/worker/internal/jobs/backtest_jobs.go:41`, `services/worker/internal/jobs/backtest_jobs.go:90`, `services/worker/internal/jobs/backtest_starter_test.go:66`, `services/worker/internal/jobs/backtest_jobs_test.go:350`. -- `ActionStartBacktest` references: `apps/cli/internal/operator/scenario.go:35`, `apps/cli/internal/operator/scenario.go:638`, `apps/cli/internal/operator/runner.go:276`, `apps/cli/internal/operator/runner_backtest_test.go:27`, `apps/cli/internal/operator/runner_backtest_test.go:92`. - -### 분할 판단 -- split decision policy를 평가했고 multi-plan을 선택했다. -- shared task group: `agent-task/m-backtest-data-collection-infrastructure/` -- 이 subtask: `03+01,02_backtest_selector_flow`. -- predecessor `01_input_selector_contract`: PASS 완료. 증거: `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/01_input_selector_contract/complete.log`. -- predecessor `02+01_storage_bar_selector`: 아직 active plan/review 상태이며 `complete.log` 미생성. 구현 시작 전 active 또는 archived complete.log가 필요하다. - -### 범위 결정 근거 -- 새로운 universe persistence나 DB schema는 만들지 않는다. CLI scenario root `universes`를 start request selector로 materialize하는 범위까지만 다룬다. -- paper trading start flow에는 selector requirement를 강제하지 않는다. `BacktestRunSpec`은 paper trading도 재사용하므로 이 subtask의 CLI validation은 `ActionStartBacktest`에 한정한다. -- 실제 provider import 동작은 기존 `import_daily_bars`를 사용하고, provider client나 KIS adapter는 변경하지 않는다. - -### 빌드 등급 -- build lane: `cloud-G06`. async job payload, worker execution handoff, CLI scenario schema, headless fixtures를 함께 건드리는 cross-surface 변경이므로 G06로 잡는다. - -## 의존 관계 및 구현 순서 -- `01_input_selector_contract`와 `02+01_storage_bar_selector` 완료가 선행이다. -- `01_input_selector_contract`는 PASS 완료되었고, `02+01_storage_bar_selector`는 아직 active plan/review 상태다. -- 구현 시작 전에 `02+01_storage_bar_selector`의 active 또는 archived `complete.log`가 없으면 이 plan을 구현하지 말고 선행 subtask를 먼저 완료한다. -- 구현 순서: worker job payload 보존 -> CLI scenario/runner selector 생성 -> fixtures/handoff 갱신 -> 전체 검증. - -## 구현 체크리스트 -- [ ] `RunBacktestPayload`가 selector를 encode/decode하고 handler가 실행 spec에 복원하도록 구현한다. -- [ ] operator `start_backtest`가 universe/symbol/instrument_ids selector를 검증하고 `BacktestRunSpec.selector`로 전송하도록 구현한다. -- [ ] headless backtest fixtures와 handoff 문서가 import universe -> start selector -> poll/result 흐름을 증명하도록 갱신한다. -- [ ] 지정 `go test -count=1` 검증을 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -### [API-1] Async Run Payload Selector Preservation -#### 문제 -starter payload와 job handler spec 재구성이 selector를 포함하지 않는다. - -```go -// services/worker/internal/jobs/backtest_starter.go:80 -payload := RunBacktestPayload{ - RunID: string(run.ID), - StrategyID: string(spec.StrategyID), - Market: string(spec.Market), - Timeframe: string(spec.Timeframe), - From: spec.From.UTC().Format(time.RFC3339), - To: spec.To.UTC().Format(time.RFC3339), -} -``` - -```go -// services/worker/internal/jobs/backtest_jobs.go:99 -spec := backtest.RunSpec{ - StrategyID: backtest.StrategyID(p.StrategyID), - Market: market.Market(p.Market), - Timeframe: market.Timeframe(p.Timeframe), - From: fromTime, - To: toTime, -} -``` - -#### 해결 방법 -payload에 selector DTO를 추가하고 blank entry validation을 넣는다. handler가 payload selector를 `backtest.RunSpec.Selector`에 복원한 뒤 executor에 전달한다. - -```go -type RunBacktestSelectorPayload struct { - InstrumentIDs []string `json:"instrument_ids,omitempty"` - Symbols []string `json:"symbols,omitempty"` -} - -type RunBacktestPayload struct { - RunID string `json:"run_id"` - StrategyID string `json:"strategy_id"` - Market string `json:"market"` - Timeframe string `json:"timeframe"` - From string `json:"from"` - To string `json:"to"` - Selector RunBacktestSelectorPayload `json:"selector,omitempty"` -} -``` - -#### 수정 파일 및 체크리스트 -- [ ] `services/worker/internal/jobs/backtest_jobs.go`에 selector payload type, decode validation, domain conversion helper를 추가한다. -- [ ] `services/worker/internal/jobs/backtest_starter.go`가 `spec.Selector`를 payload에 encode하도록 한다. -- [ ] `services/worker/internal/jobs/backtest_starter_test.go`가 decoded payload selector를 assertion한다. -- [ ] `services/worker/internal/jobs/backtest_jobs_test.go`가 executor가 받은 run spec selector와 blank selector reject를 assertion한다. - -#### 테스트 작성 -- `TestBacktestStarterRecordsPendingAndLaunches`에 selector payload assertion 추가. -- `TestRegisterRunBacktestHandlerTransitionsSucceeded`에 `executor.run.Spec.Selector` assertion 추가. -- `TestDecodeRunBacktestPayloadRejectsMissingFields` 또는 새 test에 blank selector entry reject 추가. - -#### 중간 검증 -```bash -go test -count=1 ./services/worker/internal/jobs ./services/worker/internal/backtest -``` -예상 결과: jobs/backtest tests exit 0. - -### [API-2] Operator Start Backtest Selector -#### 문제 -operator scenario root에는 universe가 있지만 `start_backtest`는 universe를 적용하지 않고 raw market/timeframe/date만 보낸다. - -```go -// apps/cli/internal/operator/scenario.go:495 -func applyUniverseDefaults(step *Step, universeByName map[string]UniverseConfig) error { - if step.Action != ActionImportDailyBars && step.Action != ActionCollectionFreshness { - return nil - } -``` - -```go -// apps/cli/internal/operator/runner.go:276 -case ActionStartBacktest: - resp, err := client.StartBacktest(stepCtx, &altv1.StartBacktestRequest{ - Spec: &altv1.BacktestRunSpec{ - StrategyId: step.Request.StrategyID, - Market: marketByName[step.Request.Market], -``` - -#### 해결 방법 -`Request`에 repeated `instrument_ids`를 추가하고 `start_backtest`가 `universe`, `symbols`, 또는 `instrument_ids` 중 하나를 요구하게 한다. `universe`는 scenario root에서 market/symbols를 materialize한다. runner는 `BacktestInputSelector`를 만들어 `BacktestRunSpec.selector`에 넣는다. - -#### 수정 파일 및 체크리스트 -- [ ] `apps/cli/internal/operator/scenario.go`의 `Request`에 `InstrumentIDs []string yaml:"instrument_ids"`를 추가한다. -- [ ] `applyUniverseDefaults`가 `ActionStartBacktest`에도 universe defaults를 적용하도록 확장한다. -- [ ] `ActionStartBacktest` validation에 selector requirement와 blank `symbols`/`instrument_ids` reject를 추가한다. -- [ ] `apps/cli/internal/operator/runner.go`에 start selector helper를 추가하고 `BacktestRunSpec.selector`를 설정한다. -- [ ] `apps/cli/internal/operator/scenario_test.go`에 valid universe start와 missing selector reject를 추가한다. -- [ ] `apps/cli/internal/operator/runner_backtest_test.go`가 fake API에 전달된 selector를 assertion한다. - -#### 테스트 작성 -- `TestValidateStartBacktestUniverseReference`: universe가 market/symbols로 materialize되는지 검증. -- `TestValidateStartBacktestRejectsMissingSelector`: selector 없는 start가 dry-run에서 실패하는지 검증. -- `TestRunBacktestRequestOutputsRunID` 또는 별도 test: fake API가 `spec.selector.symbols=["005930"]`를 받는지 검증. - -#### 중간 검증 -```bash -go test -count=1 ./apps/cli/internal/operator -``` -예상 결과: operator scenario/runner tests exit 0. - -### [API-3] Headless Fixtures And Handoff Evidence -#### 문제 -현재 fixtures는 import와 start가 같은 종목군을 공유한다는 것을 request로 증명하지 않는다. - -```yaml -# apps/cli/testdata/operator/backtest_run_polling.yaml:19 -- id: start - action: start_backtest - request: - strategy_id: strategy-v1 - market: kr - timeframe: daily -``` - -#### 해결 방법 -backtest 관련 fixtures에 root `universes`를 두고 `import_daily_bars`와 `start_backtest`가 같은 universe를 참조하게 한다. `invalid_request_matrix`는 missing-strategy typed error를 유지해야 하므로 selector를 추가하되 strategy id는 nonexistent로 둔다. `headless_validation.md`의 checked field는 import universe와 start selector handoff를 명시한다. - -#### 수정 파일 및 체크리스트 -- [ ] `apps/cli/testdata/operator/backtest_run_request.yaml`에 universe를 추가하고 import/start가 이를 참조하게 한다. -- [ ] `apps/cli/testdata/operator/backtest_result_summary.yaml`에 universe를 추가하고 import/start가 이를 참조하게 한다. -- [ ] `apps/cli/testdata/operator/backtest_run_polling.yaml`의 start request가 `universe: kr-backtest-smoke`를 사용하게 한다. -- [ ] `apps/cli/testdata/operator/invalid_request_matrix.yaml`의 `start_backtest_missing_strategy` request에 selector를 추가해 API typed error 경로를 유지한다. -- [ ] `apps/cli/testdata/operator/headless_validation.md` checked field에 `StartBacktestRequest.spec.selector` 증거를 추가한다. -- [ ] expected JSONL fixture는 output key가 바뀌지 않으면 수정하지 않는다. - -#### 테스트 작성 -- 기존 handoff tests가 fixture 존재와 promised keys를 검증한다. -- selector가 출력되지 않는 내부 request field라 expected JSONL key 추가는 하지 않는다. - -#### 중간 검증 -```bash -go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli -``` -예상 결과: fixture/handoff tests 포함 exit 0. - -## 수정 파일 요약 -| 파일 | 항목 | -|------|------| -| `services/worker/internal/jobs/backtest_jobs.go` | API-1 | -| `services/worker/internal/jobs/backtest_jobs_test.go` | API-1 | -| `services/worker/internal/jobs/backtest_starter.go` | API-1 | -| `services/worker/internal/jobs/backtest_starter_test.go` | API-1 | -| `apps/cli/internal/operator/scenario.go` | API-2 | -| `apps/cli/internal/operator/scenario_test.go` | API-2 | -| `apps/cli/internal/operator/runner.go` | API-2 | -| `apps/cli/internal/operator/runner_backtest_test.go` | API-2 | -| `apps/cli/testdata/operator/backtest_run_request.yaml` | API-3 | -| `apps/cli/testdata/operator/backtest_result_summary.yaml` | API-3 | -| `apps/cli/testdata/operator/backtest_run_polling.yaml` | API-3 | -| `apps/cli/testdata/operator/invalid_request_matrix.yaml` | API-3 | -| `apps/cli/testdata/operator/headless_validation.md` | API-3 | - -## 최종 검증 -```bash -go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs ./services/worker/internal/socket ./services/api/internal/socket ./services/api/internal/workerclient ./apps/cli/internal/operator ./apps/cli/internal/cli -``` -예상 결과: 모든 package exit 0. Go test cache 출력은 허용하지 않는다. - -모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/cli/internal/operator/runner.go b/apps/cli/internal/operator/runner.go index 436db26..05f4acc 100644 --- a/apps/cli/internal/operator/runner.go +++ b/apps/cli/internal/operator/runner.go @@ -281,6 +281,10 @@ func runStep(ctx context.Context, client *APIClient, scenario string, step Step, Timeframe: timeframeByName[step.Request.Timeframe], FromUnixMs: step.Request.FromUnixMs, ToUnixMs: step.Request.ToUnixMs, + Selector: &altv1.BacktestInputSelector{ + InstrumentIds: step.Request.InstrumentIDs, + Symbols: step.Request.Symbols, + }, }, }) if err != nil { diff --git a/apps/cli/internal/operator/runner_backtest_test.go b/apps/cli/internal/operator/runner_backtest_test.go index 690bfc8..142b432 100644 --- a/apps/cli/internal/operator/runner_backtest_test.go +++ b/apps/cli/internal/operator/runner_backtest_test.go @@ -31,6 +31,7 @@ func TestRunBacktestRequestOutputsRunID(t *testing.T) { Timeframe: "daily", FromUnixMs: 1746057600000, ToUnixMs: 1747267200000, + Symbols: []string{"005930"}, }, Expect: Expect{ Status: "ok", @@ -57,6 +58,9 @@ func TestRunBacktestRequestOutputsRunID(t *testing.T) { if got.GetSpec().GetStrategyId() != "strategy-v1" { t.Errorf("server received strategy_id = %q, want strategy-v1", got.GetSpec().GetStrategyId()) } + if len(got.GetSpec().GetSelector().GetSymbols()) != 1 || got.GetSpec().GetSelector().GetSymbols()[0] != "005930" { + t.Errorf("server received symbols = %+v, want [005930]", got.GetSpec().GetSelector().GetSymbols()) + } } func TestRunBacktestImportThenStartFlow(t *testing.T) { @@ -96,6 +100,7 @@ func TestRunBacktestImportThenStartFlow(t *testing.T) { Timeframe: "daily", FromUnixMs: 1746057600000, ToUnixMs: 1747267200000, + Symbols: []string{"005930"}, }, Expect: Expect{Status: "ok"}, }, diff --git a/apps/cli/internal/operator/scenario.go b/apps/cli/internal/operator/scenario.go index 4bf6143..241f68a 100644 --- a/apps/cli/internal/operator/scenario.go +++ b/apps/cli/internal/operator/scenario.go @@ -339,12 +339,13 @@ type Request struct { // OperatorConfirmed, OperatorID, and ConfirmationReason carry the operator // confirmation gate. LiveOrderID targets an existing live order for cancel/get // and supports {{steps..live_order.id}} interpolation. - Broker string `yaml:"broker"` - OperatorConfirmed bool `yaml:"operator_confirmed"` - OperatorID string `yaml:"operator_id"` - ConfirmationReason string `yaml:"confirmation_reason"` - IdempotencyKey string `yaml:"idempotency_key"` - LiveOrderID string `yaml:"live_order_id"` + Broker string `yaml:"broker"` + OperatorConfirmed bool `yaml:"operator_confirmed"` + OperatorID string `yaml:"operator_id"` + ConfirmationReason string `yaml:"confirmation_reason"` + IdempotencyKey string `yaml:"idempotency_key"` + LiveOrderID string `yaml:"live_order_id"` + InstrumentIDs []string `yaml:"instrument_ids"` // Kill switch fields. Halted sets the kill switch state for set_live_kill_switch. // KillSwitchReason is the reason message carried with the state change. @@ -493,7 +494,7 @@ func (s *Scenario) Validate() error { } func applyUniverseDefaults(step *Step, universeByName map[string]UniverseConfig) error { - if step.Action != ActionImportDailyBars && step.Action != ActionCollectionFreshness { + if step.Action != ActionImportDailyBars && step.Action != ActionCollectionFreshness && step.Action != ActionStartBacktest { return nil } if step.Request.Universe == "" { @@ -516,15 +517,26 @@ func applyUniverseDefaults(step *Step, universeByName map[string]UniverseConfig) return fmt.Errorf("step %q: cannot specify both universe reference %q and inline import fields", step.ID, step.Request.Universe) } } + if step.Action == ActionStartBacktest { + if len(step.Request.Symbols) > 0 || len(step.Request.InstrumentIDs) > 0 { + return fmt.Errorf("step %q: cannot specify both universe reference %q and inline selector fields", step.ID, step.Request.Universe) + } + } // Materialize fields - step.Request.Provider = u.Provider - step.Request.SelectorKind = u.SelectorKind - step.Request.Market = u.Market - step.Request.Venue = u.Venue - step.Request.Name = u.Name - step.Request.Symbols = append([]string(nil), u.Symbols...) - if step.Action == ActionImportDailyBars { + if step.Action == ActionImportDailyBars || step.Action == ActionCollectionFreshness { + step.Request.Provider = u.Provider + step.Request.SelectorKind = u.SelectorKind + step.Request.Market = u.Market + step.Request.Venue = u.Venue + step.Request.Name = u.Name + step.Request.Symbols = append([]string(nil), u.Symbols...) + if step.Action == ActionImportDailyBars { + step.Request.Universe = "" + } + } else if step.Action == ActionStartBacktest { + step.Request.Market = u.Market + step.Request.Symbols = append([]string(nil), u.Symbols...) step.Request.Universe = "" } @@ -651,6 +663,19 @@ func validateRequest(step Step) error { if step.Request.FromUnixMs > step.Request.ToUnixMs { return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID) } + if len(step.Request.Symbols) == 0 && len(step.Request.InstrumentIDs) == 0 { + return fmt.Errorf("step %q: start_backtest requires request.universe, request.symbols, or request.instrument_ids", step.ID) + } + for _, sym := range step.Request.Symbols { + if strings.TrimSpace(sym) == "" { + return fmt.Errorf("step %q: start_backtest request.symbols cannot contain empty or blank values", step.ID) + } + } + for _, id := range step.Request.InstrumentIDs { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("step %q: start_backtest request.instrument_ids cannot contain empty or blank values", step.ID) + } + } case ActionGetBacktestRunDetail, ActionGetBacktestResult, ActionPollBacktestRun: if step.Request.RunID == "" { return fmt.Errorf("step %q: %s requires request.run_id", step.ID, step.Action) diff --git a/apps/cli/internal/operator/scenario_test.go b/apps/cli/internal/operator/scenario_test.go index 9dc94ae..55e54fe 100644 --- a/apps/cli/internal/operator/scenario_test.go +++ b/apps/cli/internal/operator/scenario_test.go @@ -202,6 +202,66 @@ steps: `, expectedErr: "cannot be after request.to_unix_ms", }, + { + name: "start_backtest_blank_symbol", + yaml: ` +name: start_backtest_blank_symbol +timeout: 1s +steps: + - id: step1 + action: start_backtest + request: + strategy_id: strat1 + market: kr + timeframe: daily + from_unix_ms: 100 + to_unix_ms: 200 + symbols: [" "] +`, + expectedErr: "start_backtest request.symbols cannot contain empty or blank values", + }, + { + name: "start_backtest_blank_instrument_id", + yaml: ` +name: start_backtest_blank_instrument_id +timeout: 1s +steps: + - id: step1 + action: start_backtest + request: + strategy_id: strat1 + market: kr + timeframe: daily + from_unix_ms: 100 + to_unix_ms: 200 + instrument_ids: [""] +`, + expectedErr: "start_backtest request.instrument_ids cannot contain empty or blank values", + }, + { + name: "start_backtest_universe_inline_conflict", + yaml: ` +name: start_backtest_universe_inline_conflict +timeout: 1s +universes: + - name: kr-backtest-smoke + provider: kis + selector_kind: watchlist + market: kr + symbols: ["005930"] +steps: + - id: step1 + action: start_backtest + request: + strategy_id: strat1 + universe: kr-backtest-smoke + symbols: ["005930"] + timeframe: daily + from_unix_ms: 100 + to_unix_ms: 200 +`, + expectedErr: "cannot specify both universe reference", + }, { name: "import_missing_provider", yaml: ` @@ -1227,3 +1287,65 @@ steps: }) } } + +func TestValidateStartBacktestUniverseReference(t *testing.T) { + yamlText := ` +name: valid_start_backtest_universe +universes: + - name: kr-backtest-smoke + provider: kis + selector_kind: watchlist + market: kr + symbols: ["005930"] +steps: + - id: step1 + action: start_backtest + request: + strategy_id: strat1 + universe: kr-backtest-smoke + timeframe: daily + from_unix_ms: 100 + to_unix_ms: 200 + expect: + status: ok +` + sc, err := ParseScenario([]byte(yamlText)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sc.Steps) != 1 { + t.Fatalf("steps = %d, want 1", len(sc.Steps)) + } + step := sc.Steps[0] + if step.Request.Market != "kr" { + t.Errorf("market = %q, want kr", step.Request.Market) + } + if len(step.Request.Symbols) != 1 || step.Request.Symbols[0] != "005930" { + t.Errorf("symbols = %v, want [005930]", step.Request.Symbols) + } + if step.Request.Universe != "" { + t.Errorf("universe = %q, want empty", step.Request.Universe) + } +} + +func TestValidateStartBacktestRejectsMissingSelector(t *testing.T) { + yamlText := ` +name: missing_selector +steps: + - id: step1 + action: start_backtest + request: + strategy_id: strat1 + market: kr + timeframe: daily + from_unix_ms: 100 + to_unix_ms: 200 +` + _, err := ParseScenario([]byte(yamlText)) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "start_backtest requires request.universe, request.symbols, or request.instrument_ids") { + t.Errorf("error = %v, want it to contain missing selector error", err) + } +} diff --git a/apps/cli/testdata/operator/backtest_result_summary.yaml b/apps/cli/testdata/operator/backtest_result_summary.yaml index a98b588..471dc24 100644 --- a/apps/cli/testdata/operator/backtest_result_summary.yaml +++ b/apps/cli/testdata/operator/backtest_result_summary.yaml @@ -1,14 +1,17 @@ name: backtest_result_summary timeout: 5s +universes: + - name: kr-backtest-smoke + provider: kis + selector_kind: watchlist + market: kr + venue: krx + symbols: ["005930"] steps: - id: import_kr_daily_bars action: import_daily_bars request: - provider: kis - selector_kind: watchlist - market: kr - venue: krx - symbols: ["005930"] + universe: kr-backtest-smoke from_yyyymmdd: "20240527" to_yyyymmdd: "20240528" expect: @@ -17,7 +20,7 @@ steps: action: start_backtest request: strategy_id: strategy-v1 - market: kr + universe: kr-backtest-smoke timeframe: daily from_unix_ms: 1746057600000 to_unix_ms: 1747267200000 diff --git a/apps/cli/testdata/operator/backtest_run_polling.yaml b/apps/cli/testdata/operator/backtest_run_polling.yaml index 21a3889..60a1ac2 100644 --- a/apps/cli/testdata/operator/backtest_run_polling.yaml +++ b/apps/cli/testdata/operator/backtest_run_polling.yaml @@ -20,7 +20,7 @@ steps: action: start_backtest request: strategy_id: strategy-v1 - market: kr + universe: kr-backtest-smoke timeframe: daily from_unix_ms: 1746057600000 to_unix_ms: 1747267200000 diff --git a/apps/cli/testdata/operator/backtest_run_request.yaml b/apps/cli/testdata/operator/backtest_run_request.yaml index ab641f3..8bb4d5b 100644 --- a/apps/cli/testdata/operator/backtest_run_request.yaml +++ b/apps/cli/testdata/operator/backtest_run_request.yaml @@ -1,14 +1,17 @@ name: backtest_run_request timeout: 5s +universes: + - name: kr-backtest-smoke + provider: kis + selector_kind: watchlist + market: kr + venue: krx + symbols: ["005930"] steps: - id: import_kr_daily_bars action: import_daily_bars request: - provider: kis - selector_kind: watchlist - market: kr - venue: krx - symbols: ["005930"] + universe: kr-backtest-smoke from_yyyymmdd: "20240527" to_yyyymmdd: "20240528" expect: @@ -17,7 +20,7 @@ steps: action: start_backtest request: strategy_id: strategy-v1 - market: kr + universe: kr-backtest-smoke timeframe: daily from_unix_ms: 1746057600000 to_unix_ms: 1747267200000 diff --git a/apps/cli/testdata/operator/headless_validation.md b/apps/cli/testdata/operator/headless_validation.md index 02f8b19..5aa0ccc 100644 --- a/apps/cli/testdata/operator/headless_validation.md +++ b/apps/cli/testdata/operator/headless_validation.md @@ -32,8 +32,8 @@ ALT 운영 UI 게이트(`agent-ops/rules/project/rules.md`의 "운영 UI 구현 | `api_connection_smoke` | `alt operator scenario run --file testdata/operator/api_connection_smoke.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/api_connection_smoke.yaml` | `testdata/operator/expected/api_connection_smoke.jsonl` | `scenario`, `status`, `type`, `action` | `0` (성공), `3` (transport 실패 시) | yes | connection status display | `HelloResponse.capabilities`; summary `status`/`exit_code` | Flutter capability chip layout 미정 | | `market_data_status_query` | `alt operator scenario run --file testdata/operator/market_data_status_query.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/market_data_status_query.yaml` | `testdata/operator/expected/market_data_status_query.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `count`, `universe`, `freshness_status`, `missing_count`, `latest_yyyymmdd`, `gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days` | `0` (성공/empty), `1` (mismatch) | yes | market data status card (종목수·바개수·최신도) | `import_daily_bars` → `ImportDailyBarsResponse.instrument_count`/`bar_count`; `ListInstrumentsResponse.instruments`, `ListBarsResponse.bars`; `collection_freshness` → `freshness_status`, `missing_count`, `latest_yyyymmdd`; `ErrorInfo.code` | 차트/바 시각화 레이아웃 미정 | | `us_market_data_status_query` | `alt operator scenario run --file testdata/operator/us_market_data_status_query.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/us_market_data_status_query.yaml` | `testdata/operator/expected/us_market_data_status_query.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `count`, `universe`, `freshness_status`, `missing_count`, `latest_yyyymmdd`, `gap_status`, `gap_count`, `duplicate_count`, `provider_delay_days` | `0` (성공/empty), `1` (mismatch) | yes | market data status card (종목수·바개수·최신도) | `import_daily_bars` → `ImportDailyBarsResponse.instrument_count`/`bar_count`; `ListInstrumentsResponse.instruments`, `ListBarsResponse.bars`; `collection_freshness` → `freshness_status`, `missing_count`, `latest_yyyymmdd`; `ErrorInfo.code` | 차트/바 시각화 레이아웃 미정 | -| `backtest_run_request` | `alt operator scenario run --file testdata/operator/backtest_run_request.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_run_request.yaml` | `testdata/operator/expected/backtest_run_request.jsonl` | `scenario`, `status`, `type`, `action`, `run_id`, `run_status` | `0` (성공), `1` (typed error/mismatch) | yes | backtest 생성 form (parameter 입력/검증) | `StartBacktestResponse.run.id`, `BacktestRun.status` | form validation layout 미정 | -| `backtest_run_polling` | `alt operator scenario run --file testdata/operator/backtest_run_polling.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_run_polling.yaml` | `testdata/operator/expected/backtest_run_polling.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `run_id`, `run_status` | `0` (terminal 도달), `3` (polling timeout) | yes | backtest 진행 상태 표시 (상태 칩/진행바) | named universe `import_daily_bars` → `ImportDailyBarsResponse.provider`/`instrument_count`/`bar_count`; `StartBacktestResponse.run.id`; `GetBacktestRunResponse.run.status` 전이 (pending→running→succeeded/failed/canceled) | 진행바/상태 전이 애니메이션 미정 | +| `backtest_run_request` | `alt operator scenario run --file testdata/operator/backtest_run_request.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_run_request.yaml` | `testdata/operator/expected/backtest_run_request.jsonl` | `scenario`, `status`, `type`, `action`, `run_id`, `run_status` | `0` (성공), `1` (typed error/mismatch) | yes | backtest 생성 form (parameter 입력/검증) | `StartBacktestRequest.spec.selector`; `StartBacktestResponse.run.id`, `BacktestRun.status` | form validation layout 미정 | +| `backtest_run_polling` | `alt operator scenario run --file testdata/operator/backtest_run_polling.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_run_polling.yaml` | `testdata/operator/expected/backtest_run_polling.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `run_id`, `run_status` | `0` (terminal 도달), `3` (polling timeout) | yes | backtest 진행 상태 표시 (상태 칩/진행바) | named universe `import_daily_bars` → `ImportDailyBarsResponse.provider`/`instrument_count`/`bar_count`; `StartBacktestRequest.spec.selector`; `StartBacktestResponse.run.id`; `GetBacktestRunResponse.run.status` 전이 (pending→running→succeeded/failed/canceled) | 진행바/상태 전이 애니메이션 미정 | | `backtest_result_summary` | `alt operator scenario run --file testdata/operator/backtest_result_summary.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_result_summary.yaml` | `testdata/operator/expected/backtest_result_summary.jsonl` | `scenario`, `status`, `type`, `action`, `run_id`, `starting_cash`, `ending_equity`, `total_return`, `trade_count` | `0` (성공), `1` (missing run/typed error) | yes | backtest 결과 요약 (metrics chips) | `GetBacktestResultResponse.result.summary` | 차트/메트릭 레이아웃 미정 | | `invalid_request_matrix` | `alt operator scenario run --file testdata/operator/invalid_request_matrix.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/invalid_request_matrix.yaml` | `testdata/operator/expected/invalid_request_matrix.jsonl` | `scenario`, `status`, `type`, `action`, `error_code`, `error_message` | `0` (기대한 typed error 도달), `2` (malformed scenario) | no (개발/검증 전용) | error/message 노출 (검증용) | `ErrorInfo.code` (`invalid_request`, `not_found`) | error 메시지 UI 미정 | | `kis_daily_import_smoke` | `alt operator scenario run --file testdata/operator/kis_daily_import_smoke.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/kis_daily_import_smoke.yaml` | `testdata/operator/expected/kis_daily_import_smoke.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count` | `0` (성공), `1` (mismatch/typed error) | yes | KIS import status (진행률/결과) | `ImportDailyBarsResponse.provider`, `instrument_count`, `bar_count` | import 진행 UI 미정 | diff --git a/apps/cli/testdata/operator/invalid_request_matrix.yaml b/apps/cli/testdata/operator/invalid_request_matrix.yaml index d2737d0..13b40d8 100644 --- a/apps/cli/testdata/operator/invalid_request_matrix.yaml +++ b/apps/cli/testdata/operator/invalid_request_matrix.yaml @@ -25,6 +25,7 @@ steps: timeframe: "daily" from_unix_ms: 1746057600000 to_unix_ms: 1747267200000 + symbols: ["005930"] expect: status: error error_code: not_found diff --git a/services/worker/internal/backtest/flow_test.go b/services/worker/internal/backtest/flow_test.go index b9e43e6..ffcb1c4 100644 --- a/services/worker/internal/backtest/flow_test.go +++ b/services/worker/internal/backtest/flow_test.go @@ -229,9 +229,8 @@ func TestBacktestCommandFlowFromImportedBars(t *testing.T) { // only the selected instrument's bars and never trades the unselected one. // // It drives Engine.Execute against StorageBarSource directly rather than through -// the BacktestStarter job rail, because the KindRunBacktest payload does not yet -// carry the selector (subtask 03 scope); routing through the payload would drop -// the selector and hide the storage-side behavior this subtask owns. +// the BacktestStarter job rail so the test stays focused on the storage-side +// selection behavior owned by the previous subtask. func TestBacktestCommandFlowUsesInputSelector(t *testing.T) { ctx := context.Background() store := newFlowStore() diff --git a/services/worker/internal/jobs/backtest_jobs.go b/services/worker/internal/jobs/backtest_jobs.go index 7efc215..3f2dad4 100644 --- a/services/worker/internal/jobs/backtest_jobs.go +++ b/services/worker/internal/jobs/backtest_jobs.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "git.toki-labs.com/toki/alt/packages/domain/backtest" @@ -17,14 +18,20 @@ type BacktestExecutor interface { Execute(ctx context.Context, run backtest.Run) error } +type RunBacktestSelectorPayload struct { + InstrumentIDs []string `json:"instrument_ids,omitempty"` + Symbols []string `json:"symbols,omitempty"` +} + // RunBacktestPayload is the decoded KindRunBacktest job payload. type RunBacktestPayload struct { - RunID string `json:"run_id"` - StrategyID string `json:"strategy_id"` - Market string `json:"market"` - Timeframe string `json:"timeframe"` - From string `json:"from"` - To string `json:"to"` + RunID string `json:"run_id"` + StrategyID string `json:"strategy_id"` + Market string `json:"market"` + Timeframe string `json:"timeframe"` + From string `json:"from"` + To string `json:"to"` + Selector RunBacktestSelectorPayload `json:"selector,omitempty"` } // parsePayloadDate parses standard date strings supported by the payload. @@ -75,6 +82,17 @@ func DecodeRunBacktestPayload(raw json.RawMessage) (RunBacktestPayload, error) { return RunBacktestPayload{}, fmt.Errorf("run backtest payload: from date cannot be after to date") } + for _, id := range p.Selector.InstrumentIDs { + if strings.TrimSpace(id) == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: selector contains blank instrument_id") + } + } + for _, sym := range p.Selector.Symbols { + if strings.TrimSpace(sym) == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: selector contains blank symbol") + } + } + return p, nil } @@ -96,12 +114,20 @@ func RegisterRunBacktestHandler( fromTime, _ := parsePayloadDate(p.From) toTime, _ := parsePayloadDate(p.To) + var instIDs []market.InstrumentID + for _, id := range p.Selector.InstrumentIDs { + instIDs = append(instIDs, market.InstrumentID(id)) + } spec := backtest.RunSpec{ StrategyID: backtest.StrategyID(p.StrategyID), Market: market.Market(p.Market), Timeframe: market.Timeframe(p.Timeframe), From: fromTime, To: toTime, + Selector: backtest.InputSelector{ + InstrumentIDs: instIDs, + Symbols: p.Selector.Symbols, + }, } currentTime := now().UTC() diff --git a/services/worker/internal/jobs/backtest_jobs_test.go b/services/worker/internal/jobs/backtest_jobs_test.go index af80db7..7aa0831 100644 --- a/services/worker/internal/jobs/backtest_jobs_test.go +++ b/services/worker/internal/jobs/backtest_jobs_test.go @@ -62,7 +62,11 @@ func TestRegisterRunBacktestHandlerTransitionsSucceeded(t *testing.T) { "market": "KR", "timeframe": "1d", "from": "2026-05-01T00:00:00Z", - "to": "2026-05-15T00:00:00Z" + "to": "2026-05-15T00:00:00Z", + "selector": { + "instrument_ids": ["KR:005930"], + "symbols": ["005930"] + } }`) job := Job{ @@ -80,6 +84,13 @@ func TestRegisterRunBacktestHandlerTransitionsSucceeded(t *testing.T) { t.Fatal("expected executor to be called, but it was not") } + if len(executor.run.Spec.Selector.InstrumentIDs) != 1 || executor.run.Spec.Selector.InstrumentIDs[0] != "KR:005930" { + t.Errorf("expected restored selector instrument_ids, got %+v", executor.run.Spec.Selector.InstrumentIDs) + } + if len(executor.run.Spec.Selector.Symbols) != 1 || executor.run.Spec.Selector.Symbols[0] != "005930" { + t.Errorf("expected restored selector symbols, got %+v", executor.run.Spec.Selector.Symbols) + } + // Verify transitions (new run: pending -> running -> succeeded) if len(store.upsertCalls) != 3 { t.Fatalf("expected 3 UpsertRun calls, got %d", len(store.upsertCalls)) @@ -359,6 +370,8 @@ func TestDecodeRunBacktestPayloadRejectsMissingFields(t *testing.T) { {"missing from", `{"run_id":"run-123","strategy_id":"strat-abc","market":"KR","timeframe":"1d","to":"20260515"}`}, {"missing to", `{"run_id":"run-123","strategy_id":"strat-abc","market":"KR","timeframe":"1d","from":"20260501"}`}, {"inverted dates", `{"run_id":"run-123","strategy_id":"strat-abc","market":"KR","timeframe":"1d","from":"20260515","to":"20260501"}`}, + {"blank instrument_id in selector", `{"run_id":"run-123","strategy_id":"strat-abc","market":"KR","timeframe":"1d","from":"20260501","to":"20260515","selector":{"instrument_ids":[""]}}`}, + {"blank symbol in selector", `{"run_id":"run-123","strategy_id":"strat-abc","market":"KR","timeframe":"1d","from":"20260501","to":"20260515","selector":{"symbols":[" "]}}`}, } for _, tt := range tests { diff --git a/services/worker/internal/jobs/backtest_starter.go b/services/worker/internal/jobs/backtest_starter.go index 47b33c9..d23bb25 100644 --- a/services/worker/internal/jobs/backtest_starter.go +++ b/services/worker/internal/jobs/backtest_starter.go @@ -77,6 +77,10 @@ func (s *BacktestStarter) StartBacktest(ctx context.Context, spec backtest.RunSp return backtest.Run{}, fmt.Errorf("failed to persist pending run: %w", err) } + var instIDs []string + for _, id := range spec.Selector.InstrumentIDs { + instIDs = append(instIDs, string(id)) + } payload := RunBacktestPayload{ RunID: string(run.ID), StrategyID: string(spec.StrategyID), @@ -84,6 +88,10 @@ func (s *BacktestStarter) StartBacktest(ctx context.Context, spec backtest.RunSp Timeframe: string(spec.Timeframe), From: spec.From.UTC().Format(time.RFC3339), To: spec.To.UTC().Format(time.RFC3339), + Selector: RunBacktestSelectorPayload{ + InstrumentIDs: instIDs, + Symbols: spec.Selector.Symbols, + }, } raw, err := json.Marshal(payload) if err != nil { diff --git a/services/worker/internal/jobs/backtest_starter_test.go b/services/worker/internal/jobs/backtest_starter_test.go index 6345881..615cb02 100644 --- a/services/worker/internal/jobs/backtest_starter_test.go +++ b/services/worker/internal/jobs/backtest_starter_test.go @@ -39,7 +39,12 @@ func TestBacktestStarterRecordsPendingAndLaunches(t *testing.T) { WithStarterLaunch(func(fn func()) { fn() }), // run synchronously for determinism ) - run, err := starter.StartBacktest(context.Background(), sampleSpec()) + specWithSelector := sampleSpec() + specWithSelector.Selector = backtest.InputSelector{ + InstrumentIDs: []market.InstrumentID{"KR:005930"}, + Symbols: []string{"005930"}, + } + run, err := starter.StartBacktest(context.Background(), specWithSelector) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -73,6 +78,12 @@ func TestBacktestStarterRecordsPendingAndLaunches(t *testing.T) { if decoded.StrategyID != "strat-abc" || decoded.Market != "KR" || decoded.Timeframe != "1d" { t.Errorf("payload spec mismatch: %+v", decoded) } + if len(decoded.Selector.InstrumentIDs) != 1 || decoded.Selector.InstrumentIDs[0] != "KR:005930" { + t.Errorf("payload selector instrument_ids mismatch: %+v", decoded.Selector.InstrumentIDs) + } + if len(decoded.Selector.Symbols) != 1 || decoded.Selector.Symbols[0] != "005930" { + t.Errorf("payload selector symbols mismatch: %+v", decoded.Selector.Symbols) + } } func TestBacktestStarterPersistErrorStops(t *testing.T) {