From f084fa6916f410a5e84b53705d75c991bc395e38 Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 15 Jun 2026 15:30:12 +0900 Subject: [PATCH] feat: backtest data collection infrastructure milestone completion - Update backtest-data-collection-infrastructure roadmap - Archive completed 02+01_storage_bar_selector task files - Refactor bar_source, engine, and related tests in worker service --- ...backtest-data-collection-infrastructure.md | 7 +- .../code_review_cloud_G05_0.log | 179 ++++++++++++++++ .../code_review_cloud_G05_1.log} | 108 ++++++---- .../02+01_storage_bar_selector/complete.log | 36 ++++ .../plan_cloud_G05_0.log} | 0 .../plan_cloud_G05_1.log | 192 ++++++++++++++++++ .../worker/internal/backtest/bar_source.go | 132 +++++++++++- .../internal/backtest/bar_source_test.go | 159 +++++++++++++++ services/worker/internal/backtest/engine.go | 13 +- .../worker/internal/backtest/engine_test.go | 89 +++++++- .../worker/internal/backtest/fixture_test.go | 2 +- .../worker/internal/backtest/flow_test.go | 92 +++++++++ 12 files changed, 957 insertions(+), 52 deletions(-) create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_0.log rename agent-task/{m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/CODE_REVIEW-cloud-G05.md => archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_1.log} (58%) create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/complete.log rename agent-task/{m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/PLAN-cloud-G05.md => archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_1.log diff --git a/agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md b/agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md index c8a5021..5816851 100644 --- a/agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md +++ b/agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md @@ -61,7 +61,7 @@ - 상태: 없음 - 요청일: 없음 -- 완료 근거: `watchlist-config`, `daily-collect`, `freshness-status`, `provider-boundary`, `normalized-store`, `gap-handling`, `headless-smoke` 기능 Task가 완료되었고, `input-selector` split 작업은 `01_input_selector_contract`만 PASS되었다. `02+01_storage_bar_selector`, `03+01,02_backtest_selector_flow`가 active로 남아 있어 전체 마일스톤 완료 조건은 아직 충족되지 않았다. +- 완료 근거: `watchlist-config`, `daily-collect`, `freshness-status`, `provider-boundary`, `normalized-store`, `gap-handling`, `headless-smoke` 기능 Task가 완료되었고, `input-selector` split 작업은 `01_input_selector_contract`만 PASS되었다. 2026-06-15 기준 `02+01_storage_bar_selector`, `03+01,02_backtest_selector_flow`가 active plan/review 상태로 남아 있어 전체 마일스톤 완료 조건은 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 @@ -89,8 +89,9 @@ - 완료 근거(gap-handling): `collection_freshness` scenario가 `expected_yyyymmdd` 기반 gap, duplicate, provider delay를 text/JSONL 출력과 expectation mismatch 검증에 포함하며, matched-zero-bars symbol의 double count regression을 보강했다. 2026-06-14에 `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`가 통과했다. 증거: `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/03+02_gap_handling/complete.log`. - 완료 근거(headless-smoke): `backtest_run_polling` operator scenario가 named universe 기반 `import_daily_bars` 후 `start_backtest`와 `poll_backtest_run`으로 terminal 상태까지 확인하는 headless smoke를 문서화했다. 2026-06-14에 `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`가 통과했다. - 작업 현황(input-selector): `01_input_selector_contract`는 selector contract/domain/forwarding 보존을 PASS 처리했다. 증거: `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/01_input_selector_contract/complete.log`. -- 작업 현황(input-selector): `02+01_storage_bar_selector`는 active plan/review 상태이며, `01_input_selector_contract` 선행 조건은 충족되었다. 남은 범위는 저장된 bar source와 engine selector filtering 검증이다. -- 작업 현황(input-selector): `03+01,02_backtest_selector_flow`는 active plan/review 상태이며, `02+01_storage_bar_selector` PASS 후 실행한다. 이 subtask의 `Roadmap Targets`가 `input-selector` Task를 check-on-pass로 닫는다. +- 작업 현황(input-selector): 2026-06-15 동기화 기준 active task는 `agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/`와 `agent-task/m-backtest-data-collection-infrastructure/03+01,02_backtest_selector_flow/` 두 개다. 작업 트리는 깨끗하며 active 구현 체크리스트는 아직 미완료 상태다. +- 작업 현황(input-selector): `02+01_storage_bar_selector`는 다음 실행 대상이다. `01_input_selector_contract` 선행 조건은 충족되었고, 남은 범위는 저장된 bar source와 engine selector filtering 검증이다. +- 작업 현황(input-selector): `03+01,02_backtest_selector_flow`는 `02+01_storage_bar_selector` PASS 후 실행한다. 이 subtask의 `Roadmap Targets`가 `input-selector` Task를 check-on-pass로 닫는다. - 선행 작업: Backtest Engine Baseline, Backtest Analysis Surface - 후속 작업: Backtest Scenario Automation, Data Quality Monitoring, Agent Tooling Bridge 후보 - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_0.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_0.log new file mode 100644 index 0000000..c1ff272 --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_0.log @@ -0,0 +1,179 @@ + + +# Code Review Reference - REFACTOR + +> **[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/02+01_storage_bar_selector, plan=0, tag=REFACTOR + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/`로 이동한다. 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` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Backtest Engine RunSpec Bar Boundary | [x] | +| [REFACTOR-2] Storage Selector Resolution | [x] | +| [REFACTOR-3] Worker Command Flow Evidence | [x] | + +## 구현 체크리스트 + +- [x] backtest engine 전용 RunSpec 기반 bar source 경계를 추가하고 paper trading용 `GetBars` compatibility를 유지한다. +- [x] `StorageBarSource`가 selector instrument id/symbol을 resolve해 선택된 저장 bar만 반환하도록 구현한다. +- [x] worker unit/flow tests가 selector 적용, missing selector error, 기존 full-market fallback을 검증하도록 갱신한다. +- [x] 지정 `go test -count=1` 검증을 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다. +- [x] `.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/02+01_storage_bar_selector/`를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-backtest-data-collection-infrastructure/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md`와 `CODE_REVIEW-cloud-G05.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로 이동한다. + +## 계획 대비 변경 사항 + +- REFACTOR-3 검증 방식 변경: 계획은 기존 `TestBacktestCommandFlowFromImportedBars`를 starter 경유로 확장하는 것을 제안했으나, `jobs.BacktestStarter` -> `RunBacktestPayload` -> `RegisterRunBacktestHandler` 경로는 `RunBacktestPayload`에 selector 필드가 없어 `backtest_jobs.go:99-105`에서 selector 없는 spec을 재구성하고 `backtest_jobs.go:131`에서 `run.Spec`을 덮어쓴다. 즉 payload rail을 타면 selector가 손실된다. payload selector 전달은 계획이 명시적으로 후속 `03` 범위로 둔 항목(`03에서 다룬다`)이므로, 새 flow evidence test `TestBacktestCommandFlowUsesInputSelector`는 starter/job rail 대신 `Engine.Execute`를 `StorageBarSource`에 직접 구동해 이 subtask가 소유한 engine/storage 경계의 selector 동작만 증명한다. 기존 `TestBacktestCommandFlowFromImportedBars`는 selector 없는 full-market fallback 증거로 그대로 유지했다. +- REFACTOR-2 추가 테스트: 계획 명시 3개(`TestStorageBarSourceSelectsInstrumentIDs`, `TestStorageBarSourceSelectsSymbols`, `TestStorageBarSourceRejectsMissingSelector`) 외에 empty selector fallback을 `GetBarsForRun` 경로에서도 직접 검증하기 위해 `TestStorageBarSourceEmptySelectorReadsMarket`를 추가했다. 검증 명령은 변경하지 않았다. + +## 주요 설계 결정 + +- engine-facing `BarSource` interface를 `GetBarsForRun(ctx, spec backtest.RunSpec)`로 좁혔다. selector를 포함한 RunSpec 전체를 storage 경계까지 내려보내야 engine이 selection을 떨어뜨리지 않는다. paper trading은 별도 `papertrading.BarSource` interface(`GetBars`)를 쓰므로 `StorageBarSource.GetBars`를 그대로 유지해 호환성을 보존했다. +- `StorageBarSource`는 `GetBarsForRun`/`GetBars`가 공통 `collectBars` helper를 공유하도록 리팩터링해 timestamp(+instrument id tie-breaker) 정렬과 per-instrument bar 조회 로직을 한 곳에 모았다. error message는 `backtest storage bar source:` prefix를 유지했다. +- selector resolution은 run의 market으로 후보를 먼저 좁힌 뒤(`selectInstruments`) 적용한다. run의 starting cash currency가 market으로 결정되므로 market을 권위 있는 1차 필터로 두고, 그 안에서 instrument id(exact)와 symbol(`Instrument.Symbol` + `ProviderSymbols` value)을 resolve한다. selector 값은 방어적으로 trim하며 whitespace-only는 무시한다. 요청 selector 항목이 하나라도 resolve되지 않으면 typed error를 반환해 잘못된 입력으로 조용히 실행하지 않는다(empty selector는 기존 full-market fallback). + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `StorageBarSource.GetBars`가 paper trading compatibility를 위해 남아 있는지 확인한다. +- backtest engine이 `RunSpec.Selector`를 source에 전달하고 selector를 무시하지 않는지 확인한다. +- missing selector가 조용한 empty run이 아니라 typed worker error로 이어질 수 있는 error를 반환하는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REFACTOR-1 중간 검증 +```bash +$ go test -count=1 ./services/worker/internal/backtest +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s +``` + +### REFACTOR-2 중간 검증 +```bash +$ go test -count=1 ./services/worker/internal/backtest +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s +``` + +### REFACTOR-3 중간 검증 +```bash +$ go test -count=1 ./services/worker/internal/backtest ./services/worker/internal/jobs +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s +``` + +### 최종 검증 +```bash +$ go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs +ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s +ok git.toki-labs.com/toki/alt/packages/domain/market 0.002s +ok git.toki-labs.com/toki/alt/packages/domain/trading 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s +``` + +### 보조 회귀 검증 (paper trading compatibility 및 인접 패키지) +```bash +$ go build ./services/worker/... +=== worker build ok === +$ go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket ./services/api/internal/socket ./services/api/internal/workerclient ./apps/cli/internal/operator ./apps/cli/internal/cli +ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.062s +ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.010s +ok git.toki-labs.com/toki/alt/services/api/internal/workerclient 0.411s +ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.200s +ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.006s +``` + +--- + +## Ownership + +| 섹션 | 소유자 | +|------|--------| +| 구현 항목별 완료 여부 | Implementing agent | +| 구현 체크리스트 | Implementing agent | +| 계획 대비 변경 사항 | Implementing agent | +| 주요 설계 결정 | Implementing agent | +| 사용자 리뷰 요청 | Implementing agent | +| 검증 결과 | Implementing agent | +| 코드리뷰 전용 체크리스트 | Review agent | +| 코드리뷰 결과 | Review agent | + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - Correctness: Fail + - Completeness: Fail + - Test coverage: Fail + - API contract: Pass + - Code quality: Pass + - Plan deviation: Pass + - Verification trust: Pass +- 발견된 문제: + - Required: `services/worker/internal/backtest/bar_source.go:103`에서 selector 값을 trim한 뒤 빈 값은 버리고, `services/worker/internal/backtest/bar_source.go:116`의 empty-selector fallback으로 이어집니다. 그래서 `InstrumentIDs: []market.InstrumentID{" "}` 또는 `Symbols: []string{" "}`처럼 raw selector 항목이 있었는데 whitespace-only인 입력이 full-market run으로 조용히 실행됩니다. 계획은 whitespace-only를 방어적으로 missing 처리하고, selector 항목이 resolve되지 않으면 error를 반환해야 한다고 했으므로 invalid selector가 전체 시장으로 확대되는 correctness 결함입니다. Fix: raw selector entry가 존재했는데 trim 결과가 empty인 경우 즉시 selector error를 반환하고, instrument id/symbol 각각 whitespace-only 케이스를 테스트에 추가하세요. + - Required: `services/worker/internal/backtest/engine.go:53`에서 `StorageBarSource`가 timestamp+instrument id로 정렬한 bars를 engine이 timestamp만으로 다시 `sort.Slice`합니다. 같은 timestamp의 여러 instrument bar가 들어오면 `bar_source.go:82`의 tie-breaker 계약이 engine 경계에서 보장되지 않아 strategy 처리 순서와 결과가 불안정해질 수 있습니다. Fix: engine sort comparator도 timestamp가 같을 때 `InstrumentID`를 tie-breaker로 사용하고, 동일 timestamp의 복수 instrument bars가 strategy에 deterministic order로 전달되는 테스트를 추가하세요. +- 다음 단계: FAIL이므로 user-review gate 없이 후속 `PLAN-cloud-G05.md`와 `CODE_REVIEW-cloud-G05.md`를 작성해 Required 이슈를 수정한다. diff --git a/agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/CODE_REVIEW-cloud-G05.md b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_1.log similarity index 58% rename from agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/CODE_REVIEW-cloud-G05.md rename to agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_1.log index 32a5faf..3535681 100644 --- a/agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/CODE_REVIEW-cloud-G05.md +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_1.log @@ -1,6 +1,6 @@ - + -# Code Review Reference - REFACTOR +# Code Review Reference - REVIEW_REFACTOR > **[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. @@ -13,8 +13,8 @@ ## 개요 -date=2026-06-14 -task=m-backtest-data-collection-infrastructure/02+01_storage_bar_selector, plan=0, tag=REFACTOR +date=2026-06-15 +task=m-backtest-data-collection-infrastructure/02+01_storage_bar_selector, plan=1, tag=REVIEW_REFACTOR ## 이 파일을 읽는 리뷰 에이전트에게 @@ -35,43 +35,42 @@ task=m-backtest-data-collection-infrastructure/02+01_storage_bar_selector, plan= | 항목 | 완료 여부 | |------|---------| -| [REFACTOR-1] Backtest Engine RunSpec Bar Boundary | [ ] | -| [REFACTOR-2] Storage Selector Resolution | [ ] | -| [REFACTOR-3] Worker Command Flow Evidence | [ ] | +| [REVIEW_REFACTOR-1] Whitespace Selector Error | [x] | +| [REVIEW_REFACTOR-2] Engine Same-Timestamp Tie-Breaker | [x] | ## 구현 체크리스트 -- [ ] backtest engine 전용 RunSpec 기반 bar source 경계를 추가하고 paper trading용 `GetBars` compatibility를 유지한다. -- [ ] `StorageBarSource`가 selector instrument id/symbol을 resolve해 선택된 저장 bar만 반환하도록 구현한다. -- [ ] worker unit/flow tests가 selector 적용, missing selector error, 기존 full-market fallback을 검증하도록 갱신한다. -- [ ] 지정 `go test -count=1` 검증을 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] whitespace-only selector entry가 full-market fallback으로 바뀌지 않고 error를 반환하도록 수정하고 instrument id/symbol regression tests를 추가한다. +- [x] engine의 same-timestamp bar 정렬이 instrument id tie-breaker를 보장하도록 수정하고 regression test를 추가한다. +- [x] 지정 `go test -count=1` 검증을 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G05_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/02+01_storage_bar_selector/`를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-backtest-data-collection-infrastructure/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/`를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-backtest-data-collection-infrastructure/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. - [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md`와 `CODE_REVIEW-cloud-G05.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로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +계획과 동일하게 구현했다. 변경 없음. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- **[REVIEW_REFACTOR-1]** whitespace-only selector 처리: 기존 구현은 trim 결과가 empty인 entry를 skip해 wantIDs/wantSymbols가 비면 full-market fallback으로 흘렀다. 수정 후 raw entry가 있는데 trim 결과가 empty이면 즉시 error를 반환한다. empty selector fallback(wantIDs==0 && wantSymbols==0)은 raw slice 자체가 비어 있는 경우에만 진입하므로 기존 full-market 동작은 유지된다. +- **[REVIEW_REFACTOR-2]** engine sort comparator: timestamp가 같을 때 `InstrumentID` 오름차순 tie-breaker를 추가했다. 이로써 `StorageBarSource.collectBars`와 `papertrading.Engine` sort 계약과 일관된 결정론적 순서를 engine 경계에서도 보장한다. ## 사용자 리뷰 요청 @@ -87,9 +86,10 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외 ## 리뷰어를 위한 체크포인트 -- `StorageBarSource.GetBars`가 paper trading compatibility를 위해 남아 있는지 확인한다. -- backtest engine이 `RunSpec.Selector`를 source에 전달하고 selector를 무시하지 않는지 확인한다. -- missing selector가 조용한 empty run이 아니라 typed worker error로 이어질 수 있는 error를 반환하는지 확인한다. +- whitespace-only selector entry가 empty selector fallback으로 흐르지 않고 error를 반환하는지 확인한다. +- empty selector fallback은 raw selector가 실제로 비어 있는 경우 계속 유지되는지 확인한다. +- engine sort comparator가 timestamp 동률일 때 instrument id tie-breaker를 적용하는지 확인한다. +- regression tests가 두 Required 이슈를 직접 실패시킬 수 있는 입력을 포함하는지 확인한다. ## 검증 결과 @@ -102,32 +102,52 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 - 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. - mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. -### REFACTOR-1 중간 검증 +### REVIEW_REFACTOR-1 중간 검증 ```bash $ go test -count=1 ./services/worker/internal/backtest -(output) +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s ``` -### REFACTOR-2 중간 검증 +### REVIEW_REFACTOR-2 중간 검증 ```bash $ go test -count=1 ./services/worker/internal/backtest -(output) -``` - -### REFACTOR-3 중간 검증 -```bash -$ go test -count=1 ./services/worker/internal/backtest ./services/worker/internal/jobs -(output) +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s ``` ### 최종 검증 ```bash $ go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs -(output) +ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s +ok git.toki-labs.com/toki/alt/packages/domain/market 0.002s +ok git.toki-labs.com/toki/alt/packages/domain/trading 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s +$ go test -count=1 ./services/worker/... +ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.005s +? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files] +ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading 0.005s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.002s +ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.015s +ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.003s +ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.063s +? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files] +ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.151s +? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files] ``` --- +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + ## Ownership | 섹션 | 소유자 | @@ -140,3 +160,17 @@ $ go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./s | 검증 결과 | Implementing agent | | 코드리뷰 전용 체크리스트 | Review agent | | 코드리뷰 결과 | Review agent | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - Correctness: Pass + - Completeness: Pass + - Test coverage: Pass + - API contract: Pass + - Code quality: Pass + - Plan deviation: Pass + - Verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS이므로 active plan/review를 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/complete.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/complete.log new file mode 100644 index 0000000..8cddea8 --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/complete.log @@ -0,0 +1,36 @@ +# Complete - m-backtest-data-collection-infrastructure/02+01_storage_bar_selector + +## 완료 일시 + +2026-06-15 + +## 요약 + +Storage-backed backtest bar selection follow-up completed in loop 1 with final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G05_0.log` | `code_review_cloud_G05_0.log` | FAIL | Required follow-up requested for whitespace-only selector fallback and same-timestamp engine tie-breaker. | +| `plan_cloud_G05_1.log` | `code_review_cloud_G05_1.log` | PASS | Required issues were fixed and regression tests passed. | + +## 구현/정리 내용 + +- Added `GetBarsForRun` storage selector handling while preserving `GetBars` compatibility for paper trading. +- Rejected whitespace-only selector entries instead of falling back to full-market runs. +- Added deterministic instrument-id tie-breaker sorting in the backtest engine. +- Added selector resolution, missing/blank selector, empty selector fallback, RunSpec forwarding, and same-timestamp ordering regression tests. + +## 최종 검증 + +- `go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs` - PASS; all listed packages returned `ok`. +- `go test -count=1 ./services/worker/...` - PASS; worker packages returned `ok` or `[no test files]`. + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/PLAN-cloud-G05.md b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_0.log similarity index 100% rename from agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/PLAN-cloud-G05.md rename to agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_0.log diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_1.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_1.log new file mode 100644 index 0000000..f700210 --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_1.log @@ -0,0 +1,192 @@ + +# PLAN-cloud-G05: Review Follow-up - Storage Bar Selector + +## 이 파일을 읽는 구현 에이전트에게 +이 계획은 `code_review_cloud_G05_0.log`의 FAIL Required 이슈만 수정한다. 구현 후 반드시 검증을 실행하고 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 stdout/stderr를 채운 뒤 active 파일을 유지한 채 리뷰 준비를 보고한다. 최종 PASS, log rename, `complete.log`, archive 이동은 code-review skill 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret, 또는 범위 충돌 때문에 멈춰야 하면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청`에 증거를 채운 뒤 중단한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청 사유가 아니다. + +## 배경 +첫 리뷰에서 storage selector 구현은 대부분 계획을 충족했지만, whitespace-only selector entry가 full-market fallback으로 바뀌는 correctness 결함이 발견됐다. 또한 `StorageBarSource`의 timestamp+instrument id 정렬 계약이 engine의 timestamp-only sort에서 보장되지 않는다. 이 follow-up은 두 Required 이슈만 닫고 기존 job payload selector 후속 범위는 건드리지 않는다. + +## 사용자 리뷰 요청 흐름 +구현 중 사용자 전용 결정이나 범위 충돌이 생기면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식 그대로 기록한다. 구현 에이전트는 채팅으로 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 +### 읽은 파일 +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-test/local/rules.md` +- `agent-test/local/worker-smoke.md` +- `agent-test/local/domain-model-smoke.md` +- `agent-ops/rules/project/domain/worker/rules.md` +- `agent-ops/rules/project/domain/domain-model/rules.md` +- `agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/plan_cloud_G05_0.log` +- `agent-task/m-backtest-data-collection-infrastructure/02+01_storage_bar_selector/code_review_cloud_G05_0.log` +- `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/01_input_selector_contract/complete.log` +- `packages/domain/backtest/types.go` +- `packages/domain/market/types.go` +- `services/worker/internal/storage/ports.go` +- `services/worker/internal/backtest/bar_source.go` +- `services/worker/internal/backtest/bar_source_test.go` +- `services/worker/internal/backtest/engine.go` +- `services/worker/internal/backtest/engine_test.go` +- `services/worker/internal/backtest/fixture_test.go` +- `services/worker/internal/backtest/flow_test.go` +- `services/worker/internal/backtest/strategies.go` +- `services/worker/internal/jobs/backtest_jobs.go` +- `services/worker/internal/jobs/backtest_jobs_test.go` +- `services/worker/internal/jobs/backtest_starter.go` +- `services/worker/internal/papertrading/engine.go` +- `services/worker/cmd/alt-worker/main.go` + +### 테스트 환경 규칙 +- `test_env=local`로 판단했다. +- `agent-test/local/rules.md`가 존재해 읽었다. +- 매칭 profile로 `agent-test/local/worker-smoke.md`, `agent-test/local/domain-model-smoke.md`를 읽었다. +- worker/backtest 변경이므로 `go test -count=1 ./services/worker/...`를 worker smoke로 사용한다. +- 기존 plan의 final command와 domain smoke를 유지하기 위해 `go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs`도 실행한다. +- Go test cache 출력은 허용하지 않는다. + +### 테스트 커버리지 공백 +- whitespace-only `InstrumentIDs` 또는 `Symbols` selector entry가 error로 처리되는 회귀 테스트가 없다. +- 같은 timestamp의 복수 instrument bars가 engine에서 instrument id tie-breaker 순서로 strategy에 전달되는 테스트가 없다. + +### 심볼 참조 +- renamed/removed symbol: backtest package `BarSource.GetBars`가 engine-facing `GetBarsForRun`으로 바뀌었다. +- 확인한 call sites: `services/worker/internal/backtest/engine.go`, `services/worker/internal/backtest/engine_test.go`, `services/worker/internal/backtest/fixture_test.go`, `services/worker/internal/backtest/flow_test.go`, `services/worker/cmd/alt-worker/main.go`. +- compatibility call sites: `services/worker/internal/papertrading/engine.go`는 `GetBars`를 계속 사용하고, `StorageBarSource.GetBars`는 유지되어 있다. + +### 분할 판단 +- 기존 shared task group은 `agent-task/m-backtest-data-collection-infrastructure/`다. +- 이 follow-up은 기존 split subtask `02+01_storage_bar_selector` 안에서 plan=1로 이어간다. +- predecessor `01_input_selector_contract`는 `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/01_input_selector_contract/complete.log`로 PASS 완료가 확인됐다. +- sibling `03+01,02_backtest_selector_flow`는 이 subtask PASS 후 진행한다. 이 follow-up은 새 subtask로 분리하지 않는다. + +### 범위 결정 근거 +- `RunBacktestPayload` selector 전달, starter/job rail selector 보존, CLI scenario handoff는 후속 `03+01,02_backtest_selector_flow` 범위라 수정하지 않는다. +- proto/domain selector shape는 선행 `01_input_selector_contract` 범위라 수정하지 않는다. +- `agent-roadmap/**` 문서는 이 follow-up 구현 범위가 아니다. PASS 완료 이벤트와 roadmap 갱신은 runtime/code-review 이후 경계가 맡는다. + +### 빌드 등급 +- build lane: `cloud-G05`. storage-backed backtest selector correctness와 engine order determinism을 함께 닫는 bounded follow-up이며, 기존 cloud route를 유지한다. + +## 의존 관계 및 구현 순서 +1. whitespace-only selector entry를 error로 처리하고 storage source regression test를 추가한다. +2. engine sort comparator에 instrument id tie-breaker를 추가하고 engine regression test를 추가한다. +3. 지정 검증을 fresh run으로 실행한다. + +## 구현 체크리스트 +- [ ] whitespace-only selector entry가 full-market fallback으로 바뀌지 않고 error를 반환하도록 수정하고 instrument id/symbol regression tests를 추가한다. +- [ ] engine의 same-timestamp bar 정렬이 instrument id tie-breaker를 보장하도록 수정하고 regression test를 추가한다. +- [ ] 지정 `go test -count=1` 검증을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_REFACTOR-1] Whitespace Selector Error +#### 문제 +`services/worker/internal/backtest/bar_source.go:103`에서 selector entry를 trim한 뒤 empty 값은 버린다. + +```go +// services/worker/internal/backtest/bar_source.go:103 +wantIDs := make([]string, 0, len(selector.InstrumentIDs)) +for _, id := range selector.InstrumentIDs { + if trimmed := strings.TrimSpace(string(id)); trimmed != "" { + wantIDs = append(wantIDs, trimmed) + } +} +``` + +이후 `services/worker/internal/backtest/bar_source.go:116`에서 `wantIDs`와 `wantSymbols`가 모두 비면 full-market fallback을 반환한다. raw selector 항목이 whitespace-only였던 invalid input도 전체 시장 실행으로 확대될 수 있다. + +#### 해결 방법 +raw selector slice에 entry가 있는데 trim 결과가 empty이면 해당 entry를 unresolved selector error로 처리한다. empty selector fallback은 raw selector가 실제로 비어 있거나, 유효한 entry가 전혀 요청되지 않은 명시적 empty selector에만 허용한다. + +예시 방향: + +```go +for _, id := range selector.InstrumentIDs { + trimmed := strings.TrimSpace(string(id)) + if trimmed == "" { + return nil, fmt.Errorf("backtest storage bar source: selector instrument id is empty") + } + wantIDs = append(wantIDs, trimmed) +} +``` + +symbol도 같은 방식으로 처리한다. error prefix는 기존 `backtest storage bar source:`를 유지한다. + +#### 수정 파일 및 체크리스트 +- [ ] `services/worker/internal/backtest/bar_source.go`에서 whitespace-only instrument id selector를 error로 반환한다. +- [ ] `services/worker/internal/backtest/bar_source.go`에서 whitespace-only symbol selector를 error로 반환한다. +- [ ] 기존 empty selector fallback은 selector slice가 실제로 비어 있는 경우 유지한다. + +#### 테스트 작성 +- `services/worker/internal/backtest/bar_source_test.go`에 `TestStorageBarSourceRejectsWhitespaceOnlySelector`를 추가한다. +- assertion: `InstrumentIDs: []market.InstrumentID{" "}`와 `Symbols: []string{" "}` 모두 error를 반환한다. +- 기존 `TestStorageBarSourceEmptySelectorReadsMarket`는 계속 통과해야 한다. + +#### 중간 검증 +```bash +go test -count=1 ./services/worker/internal/backtest +``` +예상 결과: backtest package tests exit 0. + +### [REVIEW_REFACTOR-2] Engine Same-Timestamp Tie-Breaker +#### 문제 +`StorageBarSource.collectBars`는 `services/worker/internal/backtest/bar_source.go:82`에서 timestamp가 같을 때 instrument id로 tie-break한다. 하지만 `services/worker/internal/backtest/engine.go:53`이 bars를 timestamp만으로 다시 정렬한다. + +```go +// services/worker/internal/backtest/engine.go:53 +sort.Slice(bars, func(i, j int) bool { + return bars[i].Timestamp.Before(bars[j].Timestamp) +}) +``` + +같은 날짜의 여러 instrument bar가 들어오면 strategy 처리 순서가 engine 경계에서 명확히 보장되지 않는다. + +#### 해결 방법 +engine sort comparator도 `StorageBarSource`와 `papertrading.Engine`처럼 timestamp가 같을 때 `InstrumentID`를 tie-breaker로 사용한다. + +```go +sort.Slice(bars, func(i, j int) bool { + if bars[i].Timestamp.Equal(bars[j].Timestamp) { + return bars[i].InstrumentID < bars[j].InstrumentID + } + return bars[i].Timestamp.Before(bars[j].Timestamp) +}) +``` + +#### 수정 파일 및 체크리스트 +- [ ] `services/worker/internal/backtest/engine.go`의 sort comparator에 instrument id tie-breaker를 추가한다. +- [ ] 기존 chronological sorting behavior는 유지한다. + +#### 테스트 작성 +- `services/worker/internal/backtest/engine_test.go`에 `TestEngineOrdersEqualTimestampBarsByInstrumentID`를 추가하거나 기존 engine test를 확장한다. +- fixture: 같은 timestamp의 `KRX:005930`, `KRX:000660` bars를 역순으로 source에 넣는다. +- assertion: strategy `Decide` 호출 순서가 instrument id 오름차순이다. + +#### 중간 검증 +```bash +go test -count=1 ./services/worker/internal/backtest +``` +예상 결과: engine ordering regression test 포함 exit 0. + +## 수정 파일 요약 +| 파일 | 항목 | +|------|------| +| `services/worker/internal/backtest/bar_source.go` | REVIEW_REFACTOR-1 | +| `services/worker/internal/backtest/bar_source_test.go` | REVIEW_REFACTOR-1 | +| `services/worker/internal/backtest/engine.go` | REVIEW_REFACTOR-2 | +| `services/worker/internal/backtest/engine_test.go` | REVIEW_REFACTOR-2 | + +## 최종 검증 +```bash +go test -count=1 ./packages/domain/... ./services/worker/internal/backtest ./services/worker/internal/jobs +go test -count=1 ./services/worker/... +``` +예상 결과: 모든 package exit 0. Go test cache 출력은 허용하지 않는다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/worker/internal/backtest/bar_source.go b/services/worker/internal/backtest/bar_source.go index 764a2fd..fc8eb1b 100644 --- a/services/worker/internal/backtest/bar_source.go +++ b/services/worker/internal/backtest/bar_source.go @@ -4,14 +4,17 @@ import ( "context" "fmt" "sort" + "strings" "time" + "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" "git.toki-labs.com/toki/alt/services/worker/internal/storage" ) // StorageBarSource adapts worker market-data storage ports to the backtest -// engine's market/timeframe/date-range input boundary. +// engine's RunSpec input boundary. It also keeps a market/timeframe/date-range +// GetBars method so paper trading can reuse the same storage adapter. type StorageBarSource struct { instruments storage.InstrumentStore bars storage.BarStore @@ -21,6 +24,29 @@ func NewStorageBarSource(instruments storage.InstrumentStore, bars storage.BarSt return &StorageBarSource{instruments: instruments, bars: bars} } +// GetBarsForRun resolves the run's input selector against stored instruments and +// returns only the selected instruments' bars. An empty selector falls back to +// the full market so existing whole-market runs keep working. +func (s *StorageBarSource) GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) { + if s == nil || s.instruments == nil || s.bars == nil { + return nil, fmt.Errorf("backtest storage bar source: storage is not configured") + } + + instruments, err := s.instruments.ListInstruments(ctx) + if err != nil { + return nil, fmt.Errorf("backtest storage bar source: list instruments: %w", err) + } + + selected, err := selectInstruments(instruments, spec.Market, spec.Selector) + if err != nil { + return nil, err + } + + return s.collectBars(ctx, selected, spec.Timeframe, spec.From, spec.To) +} + +// GetBars returns every stored bar for the given market and date range. It is +// retained for paper trading compatibility, which has no run-level selector. func (s *StorageBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { if s == nil || s.instruments == nil || s.bars == nil { return nil, fmt.Errorf("backtest storage bar source: storage is not configured") @@ -31,11 +57,21 @@ func (s *StorageBarSource) GetBars(ctx context.Context, mkt market.Market, timef return nil, fmt.Errorf("backtest storage bar source: list instruments: %w", err) } + var selected []market.Instrument + for _, inst := range instruments { + if inst.Market == mkt { + selected = append(selected, inst) + } + } + + return s.collectBars(ctx, selected, timeframe, from, to) +} + +// collectBars reads bars for each instrument and returns them sorted by +// timestamp with instrument id as a stable tie-breaker. +func (s *StorageBarSource) collectBars(ctx context.Context, instruments []market.Instrument, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { var out []market.Bar for _, inst := range instruments { - if inst.Market != mkt { - continue - } bars, err := s.bars.GetBars(ctx, inst.ID, timeframe, from, to) if err != nil { return nil, fmt.Errorf("backtest storage bar source: get bars for %q: %w", inst.ID, err) @@ -51,3 +87,91 @@ func (s *StorageBarSource) GetBars(ctx context.Context, mkt market.Market, timef }) return out, nil } + +// selectInstruments resolves the input selector within the run's market. An empty +// selector returns the whole market. Each requested instrument id and symbol must +// resolve to a stored instrument; an unresolved selector entry returns an error so +// a backtest never runs silently on the wrong input. +func selectInstruments(instruments []market.Instrument, mkt market.Market, selector backtest.InputSelector) ([]market.Instrument, error) { + inMarket := make([]market.Instrument, 0, len(instruments)) + for _, inst := range instruments { + if inst.Market == mkt { + inMarket = append(inMarket, inst) + } + } + + wantIDs := make([]string, 0, len(selector.InstrumentIDs)) + for _, id := range selector.InstrumentIDs { + trimmed := strings.TrimSpace(string(id)) + if trimmed == "" { + return nil, fmt.Errorf("backtest storage bar source: selector instrument id is empty") + } + wantIDs = append(wantIDs, trimmed) + } + wantSymbols := make([]string, 0, len(selector.Symbols)) + for _, sym := range selector.Symbols { + trimmed := strings.TrimSpace(sym) + if trimmed == "" { + return nil, fmt.Errorf("backtest storage bar source: selector symbol is empty") + } + wantSymbols = append(wantSymbols, trimmed) + } + + // Empty selector keeps the historical whole-market behavior. + if len(wantIDs) == 0 && len(wantSymbols) == 0 { + return inMarket, nil + } + + selected := make([]market.Instrument, 0, len(wantIDs)+len(wantSymbols)) + seen := make(map[market.InstrumentID]bool) + add := func(inst market.Instrument) { + if !seen[inst.ID] { + seen[inst.ID] = true + selected = append(selected, inst) + } + } + + for _, want := range wantIDs { + found := false + for _, inst := range inMarket { + if string(inst.ID) == want { + add(inst) + found = true + break + } + } + if !found { + return nil, fmt.Errorf("backtest storage bar source: selector instrument id %q not found in market %q", want, mkt) + } + } + + for _, want := range wantSymbols { + found := false + for _, inst := range inMarket { + if instrumentMatchesSymbol(inst, want) { + add(inst) + found = true + break + } + } + if !found { + return nil, fmt.Errorf("backtest storage bar source: selector symbol %q not found in market %q", want, mkt) + } + } + + return selected, nil +} + +// instrumentMatchesSymbol matches a selector symbol against the instrument's +// canonical symbol and any provider symbol value. +func instrumentMatchesSymbol(inst market.Instrument, symbol string) bool { + if strings.TrimSpace(inst.Symbol) == symbol { + return true + } + for _, providerSymbol := range inst.ProviderSymbols { + if strings.TrimSpace(providerSymbol) == symbol { + return true + } + } + return false +} diff --git a/services/worker/internal/backtest/bar_source_test.go b/services/worker/internal/backtest/bar_source_test.go index 984cbfe..827ff96 100644 --- a/services/worker/internal/backtest/bar_source_test.go +++ b/services/worker/internal/backtest/bar_source_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" ) @@ -157,3 +158,161 @@ func TestStorageBarSourceReturnsOnlyUSBarsForUSMarket(t *testing.T) { } } } + +// seedTwoKRInstruments returns a store with two KR instruments, each with one +// daily bar in the same window, so selector tests can prove only the selected +// instrument's bars are returned. +func seedTwoKRInstruments(t *testing.T) (*sourceStore, market.Instrument, market.Instrument, time.Time, time.Time) { + t.Helper() + store := &sourceStore{} + samsung := market.Instrument{ + ID: "KRX:005930", Market: market.MarketKR, Venue: market.VenueKRX, Currency: market.CurrencyKRW, + Symbol: "005930", ProviderSymbols: map[string]string{"kis": "005930.KS"}, + } + hynix := market.Instrument{ + ID: "KRX:000660", Market: market.MarketKR, Venue: market.VenueKRX, Currency: market.CurrencyKRW, + Symbol: "000660", ProviderSymbols: map[string]string{"kis": "000660.KS"}, + } + for _, inst := range []market.Instrument{samsung, hynix} { + if err := store.UpsertInstrument(context.Background(), inst); err != nil { + t.Fatalf("upsert instrument %q: %v", inst.ID, err) + } + } + day1 := time.Date(2024, 5, 27, 0, 0, 0, 0, time.UTC) + day2 := time.Date(2024, 5, 28, 0, 0, 0, 0, time.UTC) + if err := store.UpsertBar(context.Background(), market.Bar{InstrumentID: samsung.ID, Timeframe: market.TimeframeDaily, Timestamp: day1}); err != nil { + t.Fatalf("upsert samsung bar: %v", err) + } + if err := store.UpsertBar(context.Background(), market.Bar{InstrumentID: hynix.ID, Timeframe: market.TimeframeDaily, Timestamp: day2}); err != nil { + t.Fatalf("upsert hynix bar: %v", err) + } + return store, samsung, hynix, day1, day2 +} + +func TestStorageBarSourceSelectsInstrumentIDs(t *testing.T) { + store, samsung, _, day1, day2 := seedTwoKRInstruments(t) + source := NewStorageBarSource(store, store) + + got, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + Selector: backtest.InputSelector{InstrumentIDs: []market.InstrumentID{samsung.ID}}, + }) + if err != nil { + t.Fatalf("get bars for run: %v", err) + } + if len(got) != 1 { + t.Fatalf("selected bars: got %d, want 1", len(got)) + } + if got[0].InstrumentID != samsung.ID { + t.Fatalf("selected instrument id: got %q, want %q", got[0].InstrumentID, samsung.ID) + } +} + +func TestStorageBarSourceSelectsSymbols(t *testing.T) { + store, _, hynix, day1, day2 := seedTwoKRInstruments(t) + source := NewStorageBarSource(store, store) + + // Canonical symbol selects the instrument. + got, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + Selector: backtest.InputSelector{Symbols: []string{"000660"}}, + }) + if err != nil { + t.Fatalf("get bars for run by symbol: %v", err) + } + if len(got) != 1 || got[0].InstrumentID != hynix.ID { + t.Fatalf("symbol selection: got %+v, want only %q", got, hynix.ID) + } + + // Provider symbol value selects the same instrument. + gotProvider, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + Selector: backtest.InputSelector{Symbols: []string{"000660.KS"}}, + }) + if err != nil { + t.Fatalf("get bars for run by provider symbol: %v", err) + } + if len(gotProvider) != 1 || gotProvider[0].InstrumentID != hynix.ID { + t.Fatalf("provider symbol selection: got %+v, want only %q", gotProvider, hynix.ID) + } +} + +func TestStorageBarSourceRejectsMissingSelector(t *testing.T) { + store, _, _, day1, day2 := seedTwoKRInstruments(t) + source := NewStorageBarSource(store, store) + + if _, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + Selector: backtest.InputSelector{InstrumentIDs: []market.InstrumentID{"KRX:999999"}}, + }); err == nil { + t.Fatal("expected error for unresolved instrument id selector, got nil") + } + + if _, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + Selector: backtest.InputSelector{Symbols: []string{"NOPE"}}, + }); err == nil { + t.Fatal("expected error for unresolved symbol selector, got nil") + } +} + +func TestStorageBarSourceRejectsWhitespaceOnlySelector(t *testing.T) { + store, _, _, day1, day2 := seedTwoKRInstruments(t) + source := NewStorageBarSource(store, store) + + if _, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + Selector: backtest.InputSelector{InstrumentIDs: []market.InstrumentID{" "}}, + }); err == nil { + t.Fatal("expected error for whitespace-only instrument id, got nil") + } + + if _, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + Selector: backtest.InputSelector{Symbols: []string{" "}}, + }); err == nil { + t.Fatal("expected error for whitespace-only symbol, got nil") + } +} + +func TestStorageBarSourceEmptySelectorReadsMarket(t *testing.T) { + store, _, _, day1, day2 := seedTwoKRInstruments(t) + source := NewStorageBarSource(store, store) + + got, err := source.GetBarsForRun(context.Background(), backtest.RunSpec{ + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: day1, + To: day2, + }) + if err != nil { + t.Fatalf("get bars for run with empty selector: %v", err) + } + if len(got) != 2 { + t.Fatalf("empty selector should read the whole market: got %d bars, want 2", len(got)) + } + if got[0].Timestamp != day1 || got[1].Timestamp != day2 { + t.Fatalf("bars not sorted by timestamp: %+v", got) + } +} diff --git a/services/worker/internal/backtest/engine.go b/services/worker/internal/backtest/engine.go index 51d19dc..c8ffa5c 100644 --- a/services/worker/internal/backtest/engine.go +++ b/services/worker/internal/backtest/engine.go @@ -4,16 +4,17 @@ import ( "context" "fmt" "sort" - "time" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" "git.toki-labs.com/toki/alt/services/worker/internal/storage" ) -// BarSource resolves daily bars for a backtest run. +// BarSource resolves daily bars for a backtest run. It receives the full RunSpec +// so input selectors (instrument ids / symbols) reach the storage boundary and +// the engine only iterates the bars the run actually selected. type BarSource interface { - GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) + GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) } // StrategyPort resolves strategies for a backtest run. @@ -44,13 +45,15 @@ func (e *Engine) Execute(ctx context.Context, run backtest.Run) error { return fmt.Errorf("failed to get strategy %s: %w", run.Spec.StrategyID, err) } - bars, err := e.barSource.GetBars(ctx, run.Spec.Market, run.Spec.Timeframe, run.Spec.From, run.Spec.To) + bars, err := e.barSource.GetBarsForRun(ctx, run.Spec) if err != nil { return fmt.Errorf("failed to get bars: %w", err) } - // Ensure chronological sorting of bars sort.Slice(bars, func(i, j int) bool { + if bars[i].Timestamp.Equal(bars[j].Timestamp) { + return bars[i].InstrumentID < bars[j].InstrumentID + } return bars[i].Timestamp.Before(bars[j].Timestamp) }) diff --git a/services/worker/internal/backtest/engine_test.go b/services/worker/internal/backtest/engine_test.go index b296ca0..594f7b0 100644 --- a/services/worker/internal/backtest/engine_test.go +++ b/services/worker/internal/backtest/engine_test.go @@ -10,10 +10,12 @@ import ( ) type mockBarSource struct { - bars []market.Bar + bars []market.Bar + gotSpec backtest.RunSpec } -func (m *mockBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { +func (m *mockBarSource) GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) { + m.gotSpec = spec return m.bars, nil } @@ -124,3 +126,86 @@ func TestEngineCallsStrategyForBars(t *testing.T) { t.Errorf("expected position quantity 2, got %s", pos.Quantity.Amount.Value) } } + +// TestEnginePassesRunSpecToBarSource proves the engine forwards the whole RunSpec +// (including the input selector) to the bar source instead of dropping selection +// at the engine boundary. +func TestEnginePassesRunSpecToBarSource(t *testing.T) { + barSource := &mockBarSource{} + strategyPort := &mockStrategyPort{strategy: &mockStrategy{}} + engine := NewEngine(barSource, strategyPort, nil) + + run := backtest.Run{ + ID: "run-selector", + Spec: backtest.RunSpec{ + StrategyID: "test-strategy", + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), + Selector: backtest.InputSelector{ + InstrumentIDs: []market.InstrumentID{"KRX:005930"}, + Symbols: []string{"005930"}, + }, + }, + } + + if err := engine.Execute(context.Background(), run); err != nil { + t.Fatalf("Engine execution failed: %v", err) + } + + if len(barSource.gotSpec.Selector.InstrumentIDs) != 1 || + barSource.gotSpec.Selector.InstrumentIDs[0] != "KRX:005930" { + t.Errorf("selector instrument ids not forwarded: %+v", barSource.gotSpec.Selector) + } + if len(barSource.gotSpec.Selector.Symbols) != 1 || barSource.gotSpec.Selector.Symbols[0] != "005930" { + t.Errorf("selector symbols not forwarded: %+v", barSource.gotSpec.Selector) + } +} + +// TestEngineOrdersEqualTimestampBarsByInstrumentID verifies that when multiple +// bars share the same timestamp the engine passes them to strategy.Decide in +// ascending instrument id order, matching the StorageBarSource sort contract. +func TestEngineOrdersEqualTimestampBarsByInstrumentID(t *testing.T) { + sameTime := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + barA := market.Bar{ + InstrumentID: market.InstrumentID("KRX:000660"), + Timeframe: market.TimeframeDaily, + Timestamp: sameTime, + Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "80000"}}, + } + barB := market.Bar{ + InstrumentID: market.InstrumentID("KRX:005930"), + Timeframe: market.TimeframeDaily, + Timestamp: sameTime, + Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}, + } + + // Provide bars in reverse instrument-id order to prove the engine sorts them. + barSource := &mockBarSource{bars: []market.Bar{barB, barA}} + strat := &mockStrategy{} + engine := NewEngine(barSource, &mockStrategyPort{strategy: strat}, nil) + + run := backtest.Run{ + ID: "run-tie", + Spec: backtest.RunSpec{ + StrategyID: "test-strategy", + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: sameTime, + To: sameTime, + }, + } + if err := engine.Execute(context.Background(), run); err != nil { + t.Fatalf("Engine execution failed: %v", err) + } + if len(strat.decideCalls) != 2 { + t.Fatalf("expected 2 Decide calls, got %d", len(strat.decideCalls)) + } + if strat.decideCalls[0].Bar.InstrumentID != barA.InstrumentID { + t.Errorf("first Decide call: got instrument %q, want %q", strat.decideCalls[0].Bar.InstrumentID, barA.InstrumentID) + } + if strat.decideCalls[1].Bar.InstrumentID != barB.InstrumentID { + t.Errorf("second Decide call: got instrument %q, want %q", strat.decideCalls[1].Bar.InstrumentID, barB.InstrumentID) + } +} diff --git a/services/worker/internal/backtest/fixture_test.go b/services/worker/internal/backtest/fixture_test.go index c161a4f..f01b971 100644 --- a/services/worker/internal/backtest/fixture_test.go +++ b/services/worker/internal/backtest/fixture_test.go @@ -44,7 +44,7 @@ type inMemoryBarSource struct { bars []market.Bar } -func (m *inMemoryBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { +func (m *inMemoryBarSource) GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]market.Bar, error) { return m.bars, nil } diff --git a/services/worker/internal/backtest/flow_test.go b/services/worker/internal/backtest/flow_test.go index 2e9ea43..b9e43e6 100644 --- a/services/worker/internal/backtest/flow_test.go +++ b/services/worker/internal/backtest/flow_test.go @@ -223,6 +223,98 @@ func TestBacktestCommandFlowFromImportedBars(t *testing.T) { } } +// TestBacktestCommandFlowUsesInputSelector proves the input selector narrows the +// storage-backed run input at the engine/storage boundary: two KR instruments are +// imported, but the run spec selects only one, so the executed result reflects +// 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. +func TestBacktestCommandFlowUsesInputSelector(t *testing.T) { + ctx := context.Background() + store := newFlowStore() + + selectedID := market.InstrumentID("KRX:005930") + excludedID := market.InstrumentID("KRX:000660") + for _, inst := range []market.Instrument{ + {ID: selectedID, Market: market.MarketKR, Venue: market.VenueKRX, Symbol: "005930"}, + {ID: excludedID, Market: market.MarketKR, Venue: market.VenueKRX, Symbol: "000660"}, + } { + if err := store.UpsertInstrument(ctx, inst); err != nil { + t.Fatalf("upsert instrument %q: %v", inst.ID, err) + } + } + // Both instruments have bars in the same window; only the selected one should + // be processed. + for _, b := range []market.Bar{ + {InstrumentID: selectedID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}}}, + {InstrumentID: selectedID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}}}, + {InstrumentID: selectedID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}}}, + {InstrumentID: excludedID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5000"}}}, + {InstrumentID: excludedID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5100"}}}, + {InstrumentID: excludedID, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5200"}}}, + } { + if err := store.UpsertBar(ctx, b); err != nil { + t.Fatalf("upsert bar: %v", err) + } + } + + engine := workerbacktest.NewEngine( + workerbacktest.NewStorageBarSource(store, store), + workerbacktest.NewBuiltInStrategyPort(), + store, + ) + + run := domainbacktest.Run{ + ID: "run-selector", + Spec: domainbacktest.RunSpec{ + StrategyID: workerbacktest.BuiltInStrategyID, + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), + Selector: domainbacktest.InputSelector{InstrumentIDs: []market.InstrumentID{selectedID}}, + }, + Status: domainbacktest.RunStatusRunning, + } + + if err := engine.Execute(ctx, run); err != nil { + t.Fatalf("execute backtest: %v", err) + } + + result, err := store.GetResult(ctx, run.ID) + if err != nil { + t.Fatalf("get result: %v", err) + } + // Buy-and-hold buys one unit per instrument seen. With only the selected + // instrument processed, exactly one trade is recorded and it belongs to the + // selected instrument. If the unselected instrument had been processed there + // would be two trades. + if result.Summary.TradeCount != 1 { + t.Fatalf("trade count = %d, want 1 (only selected instrument processed)", result.Summary.TradeCount) + } + for _, trade := range result.Trades { + if trade.InstrumentID == excludedID { + t.Fatalf("unselected instrument %q was traded: %+v", excludedID, trade) + } + if trade.InstrumentID != selectedID { + t.Fatalf("unexpected traded instrument %q, want %q", trade.InstrumentID, selectedID) + } + } + for _, pos := range result.Positions { + if pos.InstrumentID == excludedID { + t.Fatalf("unselected instrument %q holds a position: %+v", excludedID, pos) + } + } + // Ending equity matches the single-instrument buy-and-hold run: buy 1 unit at + // 1000 on day 1, hold; equity = 9,999,000 cash + 1*1200. + if result.Summary.EndingEquity.Amount.Value != "10000200" { + t.Errorf("summary ending equity = %q, want 10000200", result.Summary.EndingEquity.Amount.Value) + } +} + func containsStatus(haystack []domainbacktest.RunStatus, needle domainbacktest.RunStatus) bool { for _, s := range haystack { if s == needle {