feat(backtest): 백테스트 선택자 플로우와 관련 테스트를 개선한다
This commit is contained in:
parent
f084fa6916
commit
33e6ec0254
16 changed files with 260 additions and 465 deletions
|
|
@ -1,150 +0,0 @@
|
|||
<!-- task=m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
<!-- task=m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow plan=0 tag=API -->
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.<id>.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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 미정 |
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ steps:
|
|||
timeframe: "daily"
|
||||
from_unix_ms: 1746057600000
|
||||
to_unix_ms: 1747267200000
|
||||
symbols: ["005930"]
|
||||
expect:
|
||||
status: error
|
||||
error_code: not_found
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue