diff --git a/agent-task/m-backtest-engine-baseline/01_worker_lifecycle/CODE_REVIEW-cloud-G06.md b/agent-task/m-backtest-engine-baseline/01_worker_lifecycle/CODE_REVIEW-cloud-G06.md deleted file mode 100644 index 0427b28..0000000 --- a/agent-task/m-backtest-engine-baseline/01_worker_lifecycle/CODE_REVIEW-cloud-G06.md +++ /dev/null @@ -1,115 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> The task is NOT complete until every implementation-owned section below is filled in. -> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. -> Fill implementation-owned sections, then stop with active files in place and report ready for review. -> If implementation is blocked by a user-only decision, 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`. -> 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-05-30 -task=m-backtest-engine-baseline/01_worker_lifecycle, plan=0, tag=API - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-cloud-G06.md` → `code_review_cloud_G06_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-engine-baseline/01_worker_lifecycle/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. -4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. -5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Worker-owned run lifecycle | [ ] | - -## 구현 체크리스트 - -- [ ] `run_backtest` payload decode와 validation을 추가한다. -- [ ] worker-owned lifecycle handler를 추가해 pending/running/succeeded/failed 상태를 `BacktestRunStore`에 기록한다. -- [ ] strategy port와 bar source를 주입받는 최소 executor를 추가한다. -- [ ] lifecycle 성공/실패 unit test를 작성하고 `go test ./services/worker/...`를 통과시킨다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- `KindRunBacktest`가 placeholder 성공 경로가 아니라 worker-owned handler로 override되는지 확인한다. -- failure path가 failed run status를 남기고 원 error를 보존하는지 확인한다. -- result persistence나 API/client 작업이 이 subtask에 섞이지 않았는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. - -### API-1 중간 검증 -``` -$ go test ./services/worker/... -(output) -``` - -### 최종 검증 -``` -$ go test ./services/worker/... -(output) -``` - ---- - -> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** -> If anything is blank, go back and fill it in before saving this file. -> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-backtest-engine-baseline/01_worker_lifecycle/PLAN-local-G05.md b/agent-task/m-backtest-engine-baseline/01_worker_lifecycle/PLAN-local-G05.md deleted file mode 100644 index 3b6ba86..0000000 --- a/agent-task/m-backtest-engine-baseline/01_worker_lifecycle/PLAN-local-G05.md +++ /dev/null @@ -1,153 +0,0 @@ - - -# Backtest Worker Lifecycle Plan - -## 이 파일을 읽는 구현 에이전트에게 - -`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 외부 환경 준비, 범위 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션을 근거와 함께 채우고 멈춘다. `USER_REVIEW.md`, archive log, `complete.log` 작성은 code-review 전용이다. - -## 배경 - -현재 worker는 `run_backtest` kind를 갖고 있지만 built-in placeholder만 등록한다. `Backtest Engine Baseline`의 lifecycle 조건을 만족하려면 worker가 run 상태 전이를 소유하고 API는 요청/조회 경계에 머물 수 있는 실행 포트가 필요하다. 이 subtask는 result persistence 전 단계로, 실행 시작부터 성공/실패 상태 기록까지의 worker lifecycle skeleton을 만든다. - -## 사용자 리뷰 요청 흐름 - -구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 이 내용을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. - -## 분석 결과 - -### 읽은 파일 - -- `agent-roadmap/current.md` -- `agent-roadmap/phase/backtest-loop/PHASE.md` -- `agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md` -- `agent-ops/rules/project/domain/domain-model/rules.md` -- `agent-ops/rules/project/domain/worker/rules.md` -- `agent-test/local/rules.md` -- `agent-test/local/domain-model-smoke.md` -- `agent-test/local/worker-smoke.md` -- `packages/domain/backtest/types.go` -- `packages/domain/backtest/types_test.go` -- `packages/domain/market/types.go` -- `services/worker/internal/jobs/job.go` -- `services/worker/internal/jobs/runner.go` -- `services/worker/internal/jobs/builtin.go` -- `services/worker/internal/jobs/runner_test.go` -- `services/worker/internal/jobs/marketdata_jobs.go` -- `services/worker/internal/jobs/marketdata_jobs_test.go` -- `services/worker/internal/storage/ports.go` -- `services/worker/internal/storage/postgres/store.go` -- `services/worker/internal/storage/postgres/mapping.go` -- `services/worker/internal/storage/postgres/mapping_test.go` -- `services/worker/cmd/alt-worker/main.go` - -### 테스트 커버리지 공백 - -- `KindRunBacktest`는 `services/worker/internal/jobs/builtin.go:21`에서 placeholder만 테스트 없이 성공한다. 새 handler decode, store 상태 전이, strategy 호출, 실패 상태 전이 테스트가 필요하다. -- `backtest.Strategy`와 `PortfolioState`는 `packages/domain/backtest/types_test.go`에서 주입/계산 기본 테스트가 있다. -- worker runner dispatch/panic/cancel coverage는 `services/worker/internal/jobs/runner_test.go`에 있다. - -### 심볼 참조 - -- renamed/removed symbols: none. -- `KindRunBacktest` call sites: `services/worker/internal/jobs/job.go:11`, `services/worker/internal/jobs/builtin.go:21`. - -### 분할 판단 - -Split policy를 먼저 평가했다. Shared task group은 `m-backtest-engine-baseline`이다. - -- `01_worker_lifecycle`: worker execution lifecycle skeleton. 독립 시작 가능. -- `02+01_result_store`: storage/protobuf result persistence. `01_worker_lifecycle`의 run execution boundary에 의존한다. -- `03+01,02_fixture_verification`: deterministic fixture full-path test. lifecycle과 result store 둘 다에 의존한다. - -worker lifecycle, storage/schema, fixture verification은 ownership과 위험이 달라 multi-plan이 맞다. - -### 범위 결정 근거 - -이 plan은 `run_backtest` worker handler와 execution skeleton만 다룬다. `backtest_results` schema, protobuf result 확장, API/client 조회 연결은 `02+01_result_store`에서 다룬다. fixture full-cycle과 `bin/test`/`bin/lint` milestone 검증은 `03+01,02_fixture_verification`에서 다룬다. - -### 빌드 등급 - -build=`local-G05`, review=`cloud-G06`. worker 내부 handler와 unit test 중심이라 local 구현이 가능하지만 lifecycle 상태 전이는 review에서 의미 검증이 필요하다. - -## 구현 체크리스트 - -- [ ] `run_backtest` payload decode와 validation을 추가한다. -- [ ] worker-owned lifecycle handler를 추가해 pending/running/succeeded/failed 상태를 `BacktestRunStore`에 기록한다. -- [ ] strategy port와 bar source를 주입받는 최소 executor를 추가한다. -- [ ] lifecycle 성공/실패 unit test를 작성하고 `go test ./services/worker/...`를 통과시킨다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -### [API-1] Worker-owned run lifecycle - -#### 문제 - -`services/worker/internal/jobs/builtin.go:21`-`24`는 `KindRunBacktest`를 성공하는 placeholder로 처리한다. - -```go -runner.Register(KindRunBacktest, func(ctx context.Context, payload json.RawMessage) error { - slog.Info("executing built-in job: run backtest", "payload_len", len(payload)) - return nil -}) -``` - -이 상태에서는 worker가 backtest 실행을 소유한다는 검증 조건을 충족하지 못하고, run 상태도 `pending/running/succeeded/failed`로 전이되지 않는다. - -#### 해결 방법 - -`services/worker/internal/jobs/backtest_jobs.go`를 추가해 `RunBacktestPayload`, `DecodeRunBacktestPayload`, `RegisterRunBacktestHandler`를 둔다. handler는 payload를 `backtest.RunSpec`으로 변환하고, `storage.BacktestRunStore`에 running 상태를 기록한 뒤 executor를 호출하고 succeeded/failed 상태로 갱신한다. - -```go -type BacktestExecutor interface { - Execute(ctx context.Context, run backtest.Run) error -} - -func RegisterRunBacktestHandler(runner *Runner, store storage.BacktestRunStore, executor BacktestExecutor, now func() time.Time) -``` - -실제 engine 계산은 작게 유지한다. bar source와 strategy port를 받는 executor skeleton은 `services/worker/internal/backtest/` 아래에 두고, result persistence는 다음 subtask로 넘긴다. - -#### 수정 파일 및 체크리스트 - -- [ ] `services/worker/internal/jobs/backtest_jobs.go` 추가 -- [ ] `services/worker/internal/jobs/backtest_jobs_test.go` 추가 -- [ ] `services/worker/internal/jobs/builtin.go`는 placeholder 유지 여부를 결정하되 live handler가 override하는 패턴을 daily import와 맞춘다. -- [ ] `services/worker/internal/backtest/engine.go` 추가 -- [ ] `services/worker/internal/backtest/engine_test.go` 추가 - -#### 테스트 작성 - -작성한다. - -- `TestRegisterRunBacktestHandlerTransitionsSucceeded`: decoded payload -> running -> succeeded UpsertRun 순서와 executor 호출 확인 -- `TestRegisterRunBacktestHandlerTransitionsFailed`: executor error -> failed UpsertRun 확인 -- `TestDecodeRunBacktestPayloadRejectsMissingFields`: strategy_id, market, timeframe, from/to 누락 또는 역전 거부 -- `TestEngineCallsStrategyForBars`: fixture bars를 strategy input으로 전달하는 최소 engine test - -#### 중간 검증 - -```bash -go test ./services/worker/... -``` - -기대 결과: exit code 0. Go test cache 출력은 허용한다. - -## 수정 파일 요약 - -| 파일 | 항목 | -|------|------| -| `services/worker/internal/jobs/backtest_jobs.go` | API-1 | -| `services/worker/internal/jobs/backtest_jobs_test.go` | API-1 | -| `services/worker/internal/jobs/builtin.go` | API-1 | -| `services/worker/internal/backtest/engine.go` | API-1 | -| `services/worker/internal/backtest/engine_test.go` | API-1 | - -## 최종 검증 - -```bash -go test ./services/worker/... -``` - -기대 결과: exit code 0. Go test cache 출력은 허용한다. - -모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-backtest-engine-baseline/02+01_result_store/CODE_REVIEW-cloud-G07.md b/agent-task/m-backtest-engine-baseline/02+01_result_store/CODE_REVIEW-cloud-G07.md deleted file mode 100644 index 62073c3..0000000 --- a/agent-task/m-backtest-engine-baseline/02+01_result_store/CODE_REVIEW-cloud-G07.md +++ /dev/null @@ -1,128 +0,0 @@ - - -# Code Review Reference - API - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> The task is NOT complete until every implementation-owned section below is filled in. -> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. -> Fill implementation-owned sections, then stop with active files in place and report ready for review. -> If implementation is blocked by a user-only decision, 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`. -> 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-05-30 -task=m-backtest-engine-baseline/02+01_result_store, plan=0, tag=API - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-engine-baseline/02+01_result_store/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. -4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. -5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [API-1] Result domain and contract shape | [ ] | -| [API-2] Result persistence and query | [ ] | - -## 구현 체크리스트 - -- [ ] `backtest.Result`에 trades/positions summary를 additive로 확장한다. -- [ ] protobuf `BacktestResult`를 additive field number로 확장하고 generated Go/Dart code를 갱신한다. -- [ ] PostgreSQL `backtest_results` schema, sqlc queries, storage port/store/mapping을 추가한다. -- [ ] result mapping/store tests와 parser map regression을 작성하거나 갱신한다. -- [ ] `bin/contracts-check`, `bin/worker-storage-gen`, `go test ./packages/domain/...`, `go test ./services/worker/...`, `go test ./services/api/...`를 통과시킨다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- protobuf field numbers 1-3이 보존되고 additive field만 추가되었는지 확인한다. -- generated Go/Dart output이 source proto와 일치하는지 확인한다. -- storage mapping이 numeric decimal을 float 의미로 손상하지 않는지 확인한다. -- `02+01_result_store`가 `01_worker_lifecycle` 완료에 의존한다는 실행 순서를 지켰는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. - -### API-1 중간 검증 -``` -$ bin/contracts-check -(output) -``` - -### API-2 중간 검증 -``` -$ bin/worker-storage-gen && go test ./services/worker/... -(output) -``` - -### 최종 검증 -``` -$ bin/contracts-check -$ bin/worker-storage-gen -$ go test ./packages/domain/... -$ go test ./services/worker/... -$ go test ./services/api/... -(output) -``` - ---- - -> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** -> If anything is blank, go back and fill it in before saving this file. -> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-backtest-engine-baseline/02+01_result_store/PLAN-cloud-G07.md b/agent-task/m-backtest-engine-baseline/02+01_result_store/PLAN-cloud-G07.md deleted file mode 100644 index 5867518..0000000 --- a/agent-task/m-backtest-engine-baseline/02+01_result_store/PLAN-cloud-G07.md +++ /dev/null @@ -1,200 +0,0 @@ - - -# Backtest Result Store Plan - -## 이 파일을 읽는 구현 에이전트에게 - -`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 외부 환경 준비, 범위 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션을 근거와 함께 채우고 멈춘다. `USER_REVIEW.md`, archive log, `complete.log` 작성은 code-review 전용이다. - -## 배경 - -`BacktestResult` 계약은 이미 있지만 worker storage에는 run만 있고 result 저장/조회가 없다. 마일스톤 범위는 starting cash, ending equity, trades/positions summary를 요구하므로 domain, storage, contract가 같은 shape로 연결되어야 한다. 이 subtask는 lifecycle handler가 만든 run을 기준으로 result persistence와 query port를 만든다. - -## 사용자 리뷰 요청 흐름 - -구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 이 내용을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. - -## 분석 결과 - -### 읽은 파일 - -- `agent-roadmap/current.md` -- `agent-roadmap/phase/backtest-loop/PHASE.md` -- `agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md` -- `agent-ops/rules/project/domain/contracts/rules.md` -- `agent-ops/rules/project/domain/domain-model/rules.md` -- `agent-ops/rules/project/domain/worker/rules.md` -- `agent-test/local/rules.md` -- `agent-test/local/contracts-smoke.md` -- `agent-test/local/domain-model-smoke.md` -- `agent-test/local/worker-smoke.md` -- `packages/domain/backtest/types.go` -- `packages/contracts/proto/alt/v1/backtest.proto` -- `services/api/internal/contracts/parser_map.go` -- `services/api/internal/contracts/parser_map_test.go` -- `services/worker/internal/storage/ports.go` -- `services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql` -- `services/worker/internal/storage/postgres/migrations/000001_worker_backbone.down.sql` -- `services/worker/internal/storage/postgres/queries/queries.sql` -- `services/worker/internal/storage/postgres/store.go` -- `services/worker/internal/storage/postgres/mapping.go` -- `services/worker/internal/storage/postgres/mapping_test.go` -- `services/worker/sqlc.yaml` -- `bin/contracts-gen` -- `bin/contracts-check` -- `bin/worker-storage-gen` - -### 테스트 커버리지 공백 - -- `services/worker/internal/storage/ports.go:22`-`25` exposes run persistence only; result store behavior has no tests. -- `services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql:23`-`33` creates `backtest_runs`, but no `backtest_results` table exists. -- `packages/contracts/proto/alt/v1/backtest.proto:51`-`55` has cash/equity only, so trades/positions summary is not representable yet. -- API parser already registers `BacktestResult` and result request/response, but generated changes still need parser map tests rerun. - -### 심볼 참조 - -- renamed/removed symbols: none. Additive proto fields only. -- `BacktestResult` references: `packages/contracts/proto/alt/v1/backtest.proto`, generated Go/Dart contracts, `services/api/internal/contracts/parser_map.go`, `services/api/internal/contracts/parser_map_test.go`, `apps/client/lib/src/contracts/alt_contracts.dart`. - -### 분할 판단 - -Split policy를 먼저 평가했다. 이 subtask는 `02+01_result_store`이며 `01_worker_lifecycle` complete.log에 의존한다. Storage/migration/protocol schema가 함께 움직이므로 별도 cloud-grade plan이 필요하다. `03+01,02_fixture_verification`은 이 subtask 이후에 전체 deterministic result를 검증한다. - -### 범위 결정 근거 - -이 plan은 result shape, persistence, query store, generated contract drift만 다룬다. 실제 worker execution wiring은 `01_worker_lifecycle` 범위다. fixture full-cycle과 `bin/test`/`bin/lint` 완료 판정은 `03+01,02_fixture_verification`에서 다룬다. - -### 빌드 등급 - -build=`cloud-G07`, review=`cloud-G07`. Migration, sqlc generation, protobuf compatibility, generated code가 함께 움직이는 고위험 cross-domain 작업이다. - -## 의존 관계 및 구현 순서 - -이 task directory 이름은 `02+01_result_store`다. 같은 task group의 `01_worker_lifecycle`이 `complete.log`를 만든 뒤 시작한다. - -## 구현 체크리스트 - -- [ ] `backtest.Result`에 trades/positions summary를 additive로 확장한다. -- [ ] protobuf `BacktestResult`를 additive field number로 확장하고 generated Go/Dart code를 갱신한다. -- [ ] PostgreSQL `backtest_results` schema, sqlc queries, storage port/store/mapping을 추가한다. -- [ ] result mapping/store tests와 parser map regression을 작성하거나 갱신한다. -- [ ] `bin/contracts-check`, `bin/worker-storage-gen`, `go test ./packages/domain/...`, `go test ./services/worker/...`, `go test ./services/api/...`를 통과시킨다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -### [API-1] Result domain and contract shape - -#### 문제 - -`packages/contracts/proto/alt/v1/backtest.proto:51`-`55`는 `BacktestResult`에 `run_id`, `starting_cash`, `ending_equity`만 둔다. - -```proto -message BacktestResult { - string run_id = 1; - Price starting_cash = 2; - Price ending_equity = 3; -} -``` - -마일스톤 범위의 trades/positions summary가 wire format과 domain vocabulary에 없다. - -#### 해결 방법 - -`packages/domain/backtest/types.go`의 `Result`에 `Trades []TradeSummary`, `Positions []PositionSummary`를 추가한다. `backtest.proto`에는 `BacktestTrade`와 `BacktestPosition` message를 추가하고 `BacktestResult`에 `repeated` fields를 새 field number로 추가한다. 기존 field number 1-3은 변경하지 않는다. - -#### 수정 파일 및 체크리스트 - -- [ ] `packages/domain/backtest/types.go` -- [ ] `packages/domain/backtest/types_test.go` -- [ ] `packages/contracts/proto/alt/v1/backtest.proto` -- [ ] `packages/contracts/gen/go/alt/v1/backtest.pb.go` generated -- [ ] `apps/client/lib/src/generated/alt/v1/backtest.pb.dart` generated -- [ ] 관련 generated enum/json 파일 generated - -#### 테스트 작성 - -작성한다. Domain result summary field construction test를 추가하고, contract generation 후 API parser map test가 계속 통과하는지 확인한다. - -#### 중간 검증 - -```bash -bin/contracts-check -``` - -기대 결과: exit code 0. Generated drift가 있으면 `bin/contracts-gen`으로 반영 후 같은 명령을 재실행한다. - -### [API-2] Result persistence and query - -#### 문제 - -`services/worker/internal/storage/ports.go:22`-`25`는 `BacktestRunStore`만 제공한다. - -```go -type BacktestRunStore interface { - UpsertRun(ctx context.Context, run backtest.Run) error - GetRun(ctx context.Context, id backtest.RunID) (backtest.Run, error) -} -``` - -`services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql:23`-`33`에도 run table만 있고 result table이 없다. - -#### 해결 방법 - -`BacktestResultStore`를 추가하고 `UpsertResult`, `GetResult`를 제공한다. Migration에는 `backtest_results`를 `run_id` PK/FK로 추가하고 cash/equity numeric fields와 trades/positions JSONB summary를 저장한다. `queries.sql`에 `UpsertResult`, `GetResult`를 추가하고 `bin/worker-storage-gen`으로 sqlc code를 갱신한다. - -#### 수정 파일 및 체크리스트 - -- [ ] `services/worker/internal/storage/ports.go` -- [ ] `services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql` -- [ ] `services/worker/internal/storage/postgres/migrations/000001_worker_backbone.down.sql` -- [ ] `services/worker/internal/storage/postgres/queries/queries.sql` -- [ ] `services/worker/internal/storage/postgres/sqlc/*.go` generated -- [ ] `services/worker/internal/storage/postgres/store.go` -- [ ] `services/worker/internal/storage/postgres/mapping.go` -- [ ] `services/worker/internal/storage/postgres/mapping_test.go` - -#### 테스트 작성 - -작성한다. - -- `TestBacktestResultMappingRoundTrip`: domain result -> sqlc params/row -> domain result -- `TestBacktestResultMappingRejectsInvalidDecimal`: invalid numeric fields fail before persistence -- Migration embed test는 기존 `migrate_test.go`가 up/down count를 확인하므로 추가 migration 파일을 만들면 version order를 맞춘다. - -#### 중간 검증 - -```bash -bin/worker-storage-gen && go test ./services/worker/... -``` - -기대 결과: exit code 0. sqlc generated output이 repo 안에 반영되어야 한다. - -## 수정 파일 요약 - -| 파일 | 항목 | -|------|------| -| `packages/domain/backtest/types.go` | API-1 | -| `packages/domain/backtest/types_test.go` | API-1 | -| `packages/contracts/proto/alt/v1/backtest.proto` | API-1 | -| `packages/contracts/gen/go/alt/v1/backtest.pb.go` | API-1 | -| `apps/client/lib/src/generated/alt/v1/backtest.pb.dart` | API-1 | -| `services/worker/internal/storage/ports.go` | API-2 | -| `services/worker/internal/storage/postgres/migrations/*.sql` | API-2 | -| `services/worker/internal/storage/postgres/queries/queries.sql` | API-2 | -| `services/worker/internal/storage/postgres/sqlc/*.go` | API-2 | -| `services/worker/internal/storage/postgres/store.go` | API-2 | -| `services/worker/internal/storage/postgres/mapping.go` | API-2 | -| `services/worker/internal/storage/postgres/mapping_test.go` | API-2 | - -## 최종 검증 - -```bash -bin/contracts-check -bin/worker-storage-gen -go test ./packages/domain/... -go test ./services/worker/... -go test ./services/api/... -``` - -기대 결과: 모든 명령 exit code 0. Generated output이 바뀌면 변경으로 포함한다. - -모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-backtest-engine-baseline/03+01,02_fixture_verification/CODE_REVIEW-cloud-G06.md b/agent-task/m-backtest-engine-baseline/03+01,02_fixture_verification/CODE_REVIEW-cloud-G06.md deleted file mode 100644 index 12f978f..0000000 --- a/agent-task/m-backtest-engine-baseline/03+01,02_fixture_verification/CODE_REVIEW-cloud-G06.md +++ /dev/null @@ -1,117 +0,0 @@ - - -# Code Review Reference - TEST - -> **[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, 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`. -> 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-05-30 -task=m-backtest-engine-baseline/03+01,02_fixture_verification, plan=0, tag=TEST - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-cloud-G06.md` → `code_review_cloud_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-engine-baseline/03+01,02_fixture_verification/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. -4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. -5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [TEST-1] Deterministic fixture full path | [ ] | - -## 구현 체크리스트 - -- [ ] fixture daily bars와 deterministic test strategy를 추가한다. -- [ ] engine execution이 동일 fixture와 strategy 입력에서 동일 result를 두 번 생성함을 검증한다. -- [ ] result store 조회가 expected starting cash, ending equity, trades/positions summary를 반환함을 검증한다. -- [ ] `bin/test`와 `bin/lint`를 실행해 fixture-test 검증 조건을 충족한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- 동일 fixture와 동일 strategy 입력이 실제로 두 번 같은 result를 내는지 확인한다. -- result store 조회까지 검증하는지, domain-only 계산 테스트로 축소되지 않았는지 확인한다. -- `bin/test`와 `bin/lint` 출력이 실제이며 toolchain blocker가 정확히 기록되었는지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. - -### TEST-1 중간 검증 -``` -$ go test ./services/worker/... -(output) -``` - -### 최종 검증 -``` -$ go test ./services/worker/... -$ bin/test -$ bin/lint -(output) -``` - ---- - -> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** -> If anything is blank, go back and fill it in before saving this file. -> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-backtest-engine-baseline/03+01,02_fixture_verification/PLAN-local-G06.md b/agent-task/m-backtest-engine-baseline/03+01,02_fixture_verification/PLAN-local-G06.md deleted file mode 100644 index ec4f750..0000000 --- a/agent-task/m-backtest-engine-baseline/03+01,02_fixture_verification/PLAN-local-G06.md +++ /dev/null @@ -1,129 +0,0 @@ - - -# Backtest Fixture Verification Plan - -## 이 파일을 읽는 구현 에이전트에게 - -`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 외부 환경 준비, 범위 충돌 없이는 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션을 근거와 함께 채우고 멈춘다. `USER_REVIEW.md`, archive log, `complete.log` 작성은 code-review 전용이다. - -## 배경 - -마일스톤의 마지막 검증은 동일 fixture daily bars와 동일 strategy 입력이 항상 같은 result를 만든다는 것이다. lifecycle과 result store가 준비된 뒤에는 engine, storage, contract shape를 잇는 deterministic test가 필요하다. 이 subtask는 full-cycle fixture와 workspace-level 검증을 묶는다. - -## 사용자 리뷰 요청 흐름 - -구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 이 내용을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. - -## 분석 결과 - -### 읽은 파일 - -- `agent-roadmap/current.md` -- `agent-roadmap/phase/backtest-loop/PHASE.md` -- `agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md` -- `agent-test/local/rules.md` -- `agent-test/local/domain-model-smoke.md` -- `agent-test/local/worker-smoke.md` -- `agent-test/local/contracts-smoke.md` -- `packages/domain/backtest/types.go` -- `packages/domain/backtest/types_test.go` -- `packages/domain/market/types.go` -- `services/worker/internal/marketdata/importer/importer.go` -- `services/worker/internal/marketdata/importer/importer_test.go` -- `services/worker/internal/storage/ports.go` -- `services/worker/internal/storage/postgres/store.go` -- `bin/test` -- `bin/lint` - -### 테스트 커버리지 공백 - -- Current tests cover importer idempotence and domain portfolio math, but no test currently drives bars -> strategy -> fills -> result store. -- `bin/test` also runs Flutter tests; this may expose environment blockers unrelated to Go backtest code and must be reported with actual output. -- `bin/lint` runs Go vet and Flutter analyze; final milestone verification requires it only after fixture path is implemented. - -### 심볼 참조 - -- renamed/removed symbols: none. -- New symbols from `01_worker_lifecycle` and `02+01_result_store` must be referenced by deterministic fixture tests. - -### 분할 판단 - -Split policy를 먼저 평가했다. 이 task directory는 `03+01,02_fixture_verification`이며 `01_worker_lifecycle`과 `02+01_result_store` 둘 다의 `complete.log`에 의존한다. Fixture verification is deliberately last because failures should point to integrated behavior, not missing lifecycle/storage foundation. - -### 범위 결정 근거 - -이 plan은 deterministic fixture와 final verification만 다룬다. New trading strategy catalog, advanced performance metrics, live/paper trading, API/client UI are out of scope. - -### 빌드 등급 - -build=`local-G06`, review=`cloud-G06`. Work is test-heavy and deterministic, but it depends on two prior subtasks and full workspace scripts. - -## 의존 관계 및 구현 순서 - -이 task directory 이름은 `03+01,02_fixture_verification`이다. 같은 task group의 `01_worker_lifecycle`과 `02+01_result_store`가 모두 `complete.log`를 만든 뒤 시작한다. - -## 구현 체크리스트 - -- [ ] fixture daily bars와 deterministic test strategy를 추가한다. -- [ ] engine execution이 동일 fixture와 strategy 입력에서 동일 result를 두 번 생성함을 검증한다. -- [ ] result store 조회가 expected starting cash, ending equity, trades/positions summary를 반환함을 검증한다. -- [ ] `bin/test`와 `bin/lint`를 실행해 fixture-test 검증 조건을 충족한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -### [TEST-1] Deterministic fixture full path - -#### 문제 - -현재 `packages/domain/backtest/types_test.go:54`-`116`은 portfolio fill/equity 계산만 검증한다. Worker 쪽에는 fixture bars가 strategy execution과 result persistence를 거쳐 같은 결과를 내는 테스트가 없다. - -```go -func TestPortfolioStateAppliesFillsAndCalculatesEquity(t *testing.T) { - state := NewPortfolioState(price("1000")) - // domain-only portfolio checks... -} -``` - -#### 해결 방법 - -`services/worker/internal/backtest/engine_test.go` 또는 `services/worker/internal/backtest/fixture_test.go`에 in-memory bar source, strategy, result store를 둔다. 동일 fixture를 두 번 실행해 `backtest.Result` 전체가 동일한지 비교하고, expected cash/equity/trades/positions를 명시한다. - -#### 수정 파일 및 체크리스트 - -- [ ] `services/worker/internal/backtest/fixture_test.go` -- [ ] 필요한 경우 `services/worker/internal/backtest/testdata/*.json` -- [ ] 필요하면 `services/worker/internal/backtest/engine.go`의 public test seam 조정 - -#### 테스트 작성 - -작성한다. - -- `TestEngineProducesDeterministicResultFromFixtureBars`: 동일 input 두 번 실행 결과가 `cmp` 또는 manual equality로 동일 -- `TestEngineStoresAndQueriesFixtureResult`: result store fake 또는 in-memory store에서 조회 결과가 expected summary와 동일 - -#### 중간 검증 - -```bash -go test ./services/worker/... -``` - -기대 결과: exit code 0. Fresh execution이 필요하므로 cache가 의심되면 `go test -count=1 ./services/worker/...`로 대체하고 reason을 review stub에 기록한다. - -## 수정 파일 요약 - -| 파일 | 항목 | -|------|------| -| `services/worker/internal/backtest/fixture_test.go` | TEST-1 | -| `services/worker/internal/backtest/testdata/*.json` | TEST-1 | -| `services/worker/internal/backtest/engine.go` | TEST-1 | - -## 최종 검증 - -```bash -go test ./services/worker/... -bin/test -bin/lint -``` - -기대 결과: 모든 명령 exit code 0. `bin/test` 또는 `bin/lint`가 Flutter/toolchain 환경 때문에 막히면 `command -v flutter` 결과와 실제 stderr를 review stub의 `사용자 리뷰 요청` 또는 검증 결과에 기록한다. - -모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/client/lib/src/generated/alt/v1/backtest.pb.dart b/apps/client/lib/src/generated/alt/v1/backtest.pb.dart index 78484a4..f198893 100644 --- a/apps/client/lib/src/generated/alt/v1/backtest.pb.dart +++ b/apps/client/lib/src/generated/alt/v1/backtest.pb.dart @@ -461,16 +461,212 @@ class GetBacktestRunResponse extends $pb.GeneratedMessage { BacktestRun ensureRun() => $_ensure(0); } +class BacktestTrade extends $pb.GeneratedMessage { + factory BacktestTrade({ + $core.String? instrumentId, + $core.String? side, + $0.Quantity? quantity, + $0.Price? price, + $fixnum.Int64? timestampUnixMs, + }) { + final result = create(); + if (instrumentId != null) result.instrumentId = instrumentId; + if (side != null) result.side = side; + if (quantity != null) result.quantity = quantity; + if (price != null) result.price = price; + if (timestampUnixMs != null) result.timestampUnixMs = timestampUnixMs; + return result; + } + + BacktestTrade._(); + + factory BacktestTrade.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory BacktestTrade.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'BacktestTrade', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'instrumentId') + ..aOS(2, _omitFieldNames ? '' : 'side') + ..aOM<$0.Quantity>(3, _omitFieldNames ? '' : 'quantity', + subBuilder: $0.Quantity.create) + ..aOM<$0.Price>(4, _omitFieldNames ? '' : 'price', + subBuilder: $0.Price.create) + ..aInt64(5, _omitFieldNames ? '' : 'timestampUnixMs') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestTrade clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestTrade copyWith(void Function(BacktestTrade) updates) => + super.copyWith((message) => updates(message as BacktestTrade)) + as BacktestTrade; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BacktestTrade create() => BacktestTrade._(); + @$core.override + BacktestTrade createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static BacktestTrade getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static BacktestTrade? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get instrumentId => $_getSZ(0); + @$pb.TagNumber(1) + set instrumentId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasInstrumentId() => $_has(0); + @$pb.TagNumber(1) + void clearInstrumentId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get side => $_getSZ(1); + @$pb.TagNumber(2) + set side($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasSide() => $_has(1); + @$pb.TagNumber(2) + void clearSide() => $_clearField(2); + + @$pb.TagNumber(3) + $0.Quantity get quantity => $_getN(2); + @$pb.TagNumber(3) + set quantity($0.Quantity value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasQuantity() => $_has(2); + @$pb.TagNumber(3) + void clearQuantity() => $_clearField(3); + @$pb.TagNumber(3) + $0.Quantity ensureQuantity() => $_ensure(2); + + @$pb.TagNumber(4) + $0.Price get price => $_getN(3); + @$pb.TagNumber(4) + set price($0.Price value) => $_setField(4, value); + @$pb.TagNumber(4) + $core.bool hasPrice() => $_has(3); + @$pb.TagNumber(4) + void clearPrice() => $_clearField(4); + @$pb.TagNumber(4) + $0.Price ensurePrice() => $_ensure(3); + + @$pb.TagNumber(5) + $fixnum.Int64 get timestampUnixMs => $_getI64(4); + @$pb.TagNumber(5) + set timestampUnixMs($fixnum.Int64 value) => $_setInt64(4, value); + @$pb.TagNumber(5) + $core.bool hasTimestampUnixMs() => $_has(4); + @$pb.TagNumber(5) + void clearTimestampUnixMs() => $_clearField(5); +} + +class BacktestPosition extends $pb.GeneratedMessage { + factory BacktestPosition({ + $core.String? instrumentId, + $0.Quantity? quantity, + $0.Price? lastPrice, + }) { + final result = create(); + if (instrumentId != null) result.instrumentId = instrumentId; + if (quantity != null) result.quantity = quantity; + if (lastPrice != null) result.lastPrice = lastPrice; + return result; + } + + BacktestPosition._(); + + factory BacktestPosition.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory BacktestPosition.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'BacktestPosition', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'instrumentId') + ..aOM<$0.Quantity>(2, _omitFieldNames ? '' : 'quantity', + subBuilder: $0.Quantity.create) + ..aOM<$0.Price>(3, _omitFieldNames ? '' : 'lastPrice', + subBuilder: $0.Price.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestPosition clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestPosition copyWith(void Function(BacktestPosition) updates) => + super.copyWith((message) => updates(message as BacktestPosition)) + as BacktestPosition; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BacktestPosition create() => BacktestPosition._(); + @$core.override + BacktestPosition createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static BacktestPosition getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static BacktestPosition? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get instrumentId => $_getSZ(0); + @$pb.TagNumber(1) + set instrumentId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasInstrumentId() => $_has(0); + @$pb.TagNumber(1) + void clearInstrumentId() => $_clearField(1); + + @$pb.TagNumber(2) + $0.Quantity get quantity => $_getN(1); + @$pb.TagNumber(2) + set quantity($0.Quantity value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasQuantity() => $_has(1); + @$pb.TagNumber(2) + void clearQuantity() => $_clearField(2); + @$pb.TagNumber(2) + $0.Quantity ensureQuantity() => $_ensure(1); + + @$pb.TagNumber(3) + $0.Price get lastPrice => $_getN(2); + @$pb.TagNumber(3) + set lastPrice($0.Price value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasLastPrice() => $_has(2); + @$pb.TagNumber(3) + void clearLastPrice() => $_clearField(3); + @$pb.TagNumber(3) + $0.Price ensureLastPrice() => $_ensure(2); +} + class BacktestResult extends $pb.GeneratedMessage { factory BacktestResult({ $core.String? runId, $0.Price? startingCash, $0.Price? endingEquity, + $core.Iterable? trades, + $core.Iterable? positions, }) { final result = create(); if (runId != null) result.runId = runId; if (startingCash != null) result.startingCash = startingCash; if (endingEquity != null) result.endingEquity = endingEquity; + if (trades != null) result.trades.addAll(trades); + if (positions != null) result.positions.addAll(positions); return result; } @@ -492,6 +688,10 @@ class BacktestResult extends $pb.GeneratedMessage { subBuilder: $0.Price.create) ..aOM<$0.Price>(3, _omitFieldNames ? '' : 'endingEquity', subBuilder: $0.Price.create) + ..pPM(4, _omitFieldNames ? '' : 'trades', + subBuilder: BacktestTrade.create) + ..pPM(5, _omitFieldNames ? '' : 'positions', + subBuilder: BacktestPosition.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -543,6 +743,12 @@ class BacktestResult extends $pb.GeneratedMessage { void clearEndingEquity() => $_clearField(3); @$pb.TagNumber(3) $0.Price ensureEndingEquity() => $_ensure(2); + + @$pb.TagNumber(4) + $pb.PbList get trades => $_getList(3); + + @$pb.TagNumber(5) + $pb.PbList get positions => $_getList(4); } class GetBacktestResultRequest extends $pb.GeneratedMessage { diff --git a/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart b/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart index 6ba530c..a10fd55 100644 --- a/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart +++ b/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart @@ -188,6 +188,69 @@ final $typed_data.Uint8List getBacktestRunResponseDescriptor = 'ChZHZXRCYWNrdGVzdFJ1blJlc3BvbnNlEiUKA3J1bhgBIAEoCzITLmFsdC52MS5CYWNrdGVzdF' 'J1blIDcnVu'); +@$core.Deprecated('Use backtestTradeDescriptor instead') +const BacktestTrade$json = { + '1': 'BacktestTrade', + '2': [ + {'1': 'instrument_id', '3': 1, '4': 1, '5': 9, '10': 'instrumentId'}, + {'1': 'side', '3': 2, '4': 1, '5': 9, '10': 'side'}, + { + '1': 'quantity', + '3': 3, + '4': 1, + '5': 11, + '6': '.alt.v1.Quantity', + '10': 'quantity' + }, + { + '1': 'price', + '3': 4, + '4': 1, + '5': 11, + '6': '.alt.v1.Price', + '10': 'price' + }, + {'1': 'timestamp_unix_ms', '3': 5, '4': 1, '5': 3, '10': 'timestampUnixMs'}, + ], +}; + +/// Descriptor for `BacktestTrade`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List backtestTradeDescriptor = $convert.base64Decode( + 'Cg1CYWNrdGVzdFRyYWRlEiMKDWluc3RydW1lbnRfaWQYASABKAlSDGluc3RydW1lbnRJZBISCg' + 'RzaWRlGAIgASgJUgRzaWRlEiwKCHF1YW50aXR5GAMgASgLMhAuYWx0LnYxLlF1YW50aXR5Ughx' + 'dWFudGl0eRIjCgVwcmljZRgEIAEoCzINLmFsdC52MS5QcmljZVIFcHJpY2USKgoRdGltZXN0YW' + '1wX3VuaXhfbXMYBSABKANSD3RpbWVzdGFtcFVuaXhNcw=='); + +@$core.Deprecated('Use backtestPositionDescriptor instead') +const BacktestPosition$json = { + '1': 'BacktestPosition', + '2': [ + {'1': 'instrument_id', '3': 1, '4': 1, '5': 9, '10': 'instrumentId'}, + { + '1': 'quantity', + '3': 2, + '4': 1, + '5': 11, + '6': '.alt.v1.Quantity', + '10': 'quantity' + }, + { + '1': 'last_price', + '3': 3, + '4': 1, + '5': 11, + '6': '.alt.v1.Price', + '10': 'lastPrice' + }, + ], +}; + +/// Descriptor for `BacktestPosition`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List backtestPositionDescriptor = $convert.base64Decode( + 'ChBCYWNrdGVzdFBvc2l0aW9uEiMKDWluc3RydW1lbnRfaWQYASABKAlSDGluc3RydW1lbnRJZB' + 'IsCghxdWFudGl0eRgCIAEoCzIQLmFsdC52MS5RdWFudGl0eVIIcXVhbnRpdHkSLAoKbGFzdF9w' + 'cmljZRgDIAEoCzINLmFsdC52MS5QcmljZVIJbGFzdFByaWNl'); + @$core.Deprecated('Use backtestResultDescriptor instead') const BacktestResult$json = { '1': 'BacktestResult', @@ -209,6 +272,22 @@ const BacktestResult$json = { '6': '.alt.v1.Price', '10': 'endingEquity' }, + { + '1': 'trades', + '3': 4, + '4': 3, + '5': 11, + '6': '.alt.v1.BacktestTrade', + '10': 'trades' + }, + { + '1': 'positions', + '3': 5, + '4': 3, + '5': 11, + '6': '.alt.v1.BacktestPosition', + '10': 'positions' + }, ], }; @@ -216,7 +295,9 @@ const BacktestResult$json = { final $typed_data.Uint8List backtestResultDescriptor = $convert.base64Decode( 'Cg5CYWNrdGVzdFJlc3VsdBIVCgZydW5faWQYASABKAlSBXJ1bklkEjIKDXN0YXJ0aW5nX2Nhc2' 'gYAiABKAsyDS5hbHQudjEuUHJpY2VSDHN0YXJ0aW5nQ2FzaBIyCg1lbmRpbmdfZXF1aXR5GAMg' - 'ASgLMg0uYWx0LnYxLlByaWNlUgxlbmRpbmdFcXVpdHk='); + 'ASgLMg0uYWx0LnYxLlByaWNlUgxlbmRpbmdFcXVpdHkSLQoGdHJhZGVzGAQgAygLMhUuYWx0Ln' + 'YxLkJhY2t0ZXN0VHJhZGVSBnRyYWRlcxI2Cglwb3NpdGlvbnMYBSADKAsyGC5hbHQudjEuQmFj' + 'a3Rlc3RQb3NpdGlvblIJcG9zaXRpb25z'); @$core.Deprecated('Use getBacktestResultRequestDescriptor instead') const GetBacktestResultRequest$json = { diff --git a/packages/contracts/gen/go/alt/v1/backtest.pb.go b/packages/contracts/gen/go/alt/v1/backtest.pb.go index 1371336..0f0b3e8 100644 --- a/packages/contracts/gen/go/alt/v1/backtest.pb.go +++ b/packages/contracts/gen/go/alt/v1/backtest.pb.go @@ -407,18 +407,156 @@ func (x *GetBacktestRunResponse) GetRun() *BacktestRun { return nil } +type BacktestTrade struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstrumentId string `protobuf:"bytes,1,opt,name=instrument_id,json=instrumentId,proto3" json:"instrument_id,omitempty"` + Side string `protobuf:"bytes,2,opt,name=side,proto3" json:"side,omitempty"` + Quantity *Quantity `protobuf:"bytes,3,opt,name=quantity,proto3" json:"quantity,omitempty"` + Price *Price `protobuf:"bytes,4,opt,name=price,proto3" json:"price,omitempty"` + TimestampUnixMs int64 `protobuf:"varint,5,opt,name=timestamp_unix_ms,json=timestampUnixMs,proto3" json:"timestamp_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BacktestTrade) Reset() { + *x = BacktestTrade{} + mi := &file_alt_v1_backtest_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BacktestTrade) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BacktestTrade) ProtoMessage() {} + +func (x *BacktestTrade) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BacktestTrade.ProtoReflect.Descriptor instead. +func (*BacktestTrade) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{6} +} + +func (x *BacktestTrade) GetInstrumentId() string { + if x != nil { + return x.InstrumentId + } + return "" +} + +func (x *BacktestTrade) GetSide() string { + if x != nil { + return x.Side + } + return "" +} + +func (x *BacktestTrade) GetQuantity() *Quantity { + if x != nil { + return x.Quantity + } + return nil +} + +func (x *BacktestTrade) GetPrice() *Price { + if x != nil { + return x.Price + } + return nil +} + +func (x *BacktestTrade) GetTimestampUnixMs() int64 { + if x != nil { + return x.TimestampUnixMs + } + return 0 +} + +type BacktestPosition struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstrumentId string `protobuf:"bytes,1,opt,name=instrument_id,json=instrumentId,proto3" json:"instrument_id,omitempty"` + Quantity *Quantity `protobuf:"bytes,2,opt,name=quantity,proto3" json:"quantity,omitempty"` + LastPrice *Price `protobuf:"bytes,3,opt,name=last_price,json=lastPrice,proto3" json:"last_price,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BacktestPosition) Reset() { + *x = BacktestPosition{} + mi := &file_alt_v1_backtest_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BacktestPosition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BacktestPosition) ProtoMessage() {} + +func (x *BacktestPosition) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BacktestPosition.ProtoReflect.Descriptor instead. +func (*BacktestPosition) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{7} +} + +func (x *BacktestPosition) GetInstrumentId() string { + if x != nil { + return x.InstrumentId + } + return "" +} + +func (x *BacktestPosition) GetQuantity() *Quantity { + if x != nil { + return x.Quantity + } + return nil +} + +func (x *BacktestPosition) GetLastPrice() *Price { + if x != nil { + return x.LastPrice + } + return nil +} + type BacktestResult struct { state protoimpl.MessageState `protogen:"open.v1"` RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` StartingCash *Price `protobuf:"bytes,2,opt,name=starting_cash,json=startingCash,proto3" json:"starting_cash,omitempty"` EndingEquity *Price `protobuf:"bytes,3,opt,name=ending_equity,json=endingEquity,proto3" json:"ending_equity,omitempty"` + Trades []*BacktestTrade `protobuf:"bytes,4,rep,name=trades,proto3" json:"trades,omitempty"` + Positions []*BacktestPosition `protobuf:"bytes,5,rep,name=positions,proto3" json:"positions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BacktestResult) Reset() { *x = BacktestResult{} - mi := &file_alt_v1_backtest_proto_msgTypes[6] + mi := &file_alt_v1_backtest_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -430,7 +568,7 @@ func (x *BacktestResult) String() string { func (*BacktestResult) ProtoMessage() {} func (x *BacktestResult) ProtoReflect() protoreflect.Message { - mi := &file_alt_v1_backtest_proto_msgTypes[6] + mi := &file_alt_v1_backtest_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -443,7 +581,7 @@ func (x *BacktestResult) ProtoReflect() protoreflect.Message { // Deprecated: Use BacktestResult.ProtoReflect.Descriptor instead. func (*BacktestResult) Descriptor() ([]byte, []int) { - return file_alt_v1_backtest_proto_rawDescGZIP(), []int{6} + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{8} } func (x *BacktestResult) GetRunId() string { @@ -467,6 +605,20 @@ func (x *BacktestResult) GetEndingEquity() *Price { return nil } +func (x *BacktestResult) GetTrades() []*BacktestTrade { + if x != nil { + return x.Trades + } + return nil +} + +func (x *BacktestResult) GetPositions() []*BacktestPosition { + if x != nil { + return x.Positions + } + return nil +} + type GetBacktestResultRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` @@ -476,7 +628,7 @@ type GetBacktestResultRequest struct { func (x *GetBacktestResultRequest) Reset() { *x = GetBacktestResultRequest{} - mi := &file_alt_v1_backtest_proto_msgTypes[7] + mi := &file_alt_v1_backtest_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -488,7 +640,7 @@ func (x *GetBacktestResultRequest) String() string { func (*GetBacktestResultRequest) ProtoMessage() {} func (x *GetBacktestResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_alt_v1_backtest_proto_msgTypes[7] + mi := &file_alt_v1_backtest_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -501,7 +653,7 @@ func (x *GetBacktestResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBacktestResultRequest.ProtoReflect.Descriptor instead. func (*GetBacktestResultRequest) Descriptor() ([]byte, []int) { - return file_alt_v1_backtest_proto_rawDescGZIP(), []int{7} + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{9} } func (x *GetBacktestResultRequest) GetRunId() string { @@ -520,7 +672,7 @@ type GetBacktestResultResponse struct { func (x *GetBacktestResultResponse) Reset() { *x = GetBacktestResultResponse{} - mi := &file_alt_v1_backtest_proto_msgTypes[8] + mi := &file_alt_v1_backtest_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -532,7 +684,7 @@ func (x *GetBacktestResultResponse) String() string { func (*GetBacktestResultResponse) ProtoMessage() {} func (x *GetBacktestResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_alt_v1_backtest_proto_msgTypes[8] + mi := &file_alt_v1_backtest_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -545,7 +697,7 @@ func (x *GetBacktestResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBacktestResultResponse.ProtoReflect.Descriptor instead. func (*GetBacktestResultResponse) Descriptor() ([]byte, []int) { - return file_alt_v1_backtest_proto_rawDescGZIP(), []int{8} + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{10} } func (x *GetBacktestResultResponse) GetResult() *BacktestResult { @@ -582,11 +734,24 @@ const file_alt_v1_backtest_proto_rawDesc = "" + "\x15GetBacktestRunRequest\x12\x15\n" + "\x06run_id\x18\x01 \x01(\tR\x05runId\"?\n" + "\x16GetBacktestRunResponse\x12%\n" + - "\x03run\x18\x01 \x01(\v2\x13.alt.v1.BacktestRunR\x03run\"\x8f\x01\n" + + "\x03run\x18\x01 \x01(\v2\x13.alt.v1.BacktestRunR\x03run\"\xc7\x01\n" + + "\rBacktestTrade\x12#\n" + + "\rinstrument_id\x18\x01 \x01(\tR\finstrumentId\x12\x12\n" + + "\x04side\x18\x02 \x01(\tR\x04side\x12,\n" + + "\bquantity\x18\x03 \x01(\v2\x10.alt.v1.QuantityR\bquantity\x12#\n" + + "\x05price\x18\x04 \x01(\v2\r.alt.v1.PriceR\x05price\x12*\n" + + "\x11timestamp_unix_ms\x18\x05 \x01(\x03R\x0ftimestampUnixMs\"\x93\x01\n" + + "\x10BacktestPosition\x12#\n" + + "\rinstrument_id\x18\x01 \x01(\tR\finstrumentId\x12,\n" + + "\bquantity\x18\x02 \x01(\v2\x10.alt.v1.QuantityR\bquantity\x12,\n" + + "\n" + + "last_price\x18\x03 \x01(\v2\r.alt.v1.PriceR\tlastPrice\"\xf6\x01\n" + "\x0eBacktestResult\x12\x15\n" + "\x06run_id\x18\x01 \x01(\tR\x05runId\x122\n" + "\rstarting_cash\x18\x02 \x01(\v2\r.alt.v1.PriceR\fstartingCash\x122\n" + - "\rending_equity\x18\x03 \x01(\v2\r.alt.v1.PriceR\fendingEquity\"1\n" + + "\rending_equity\x18\x03 \x01(\v2\r.alt.v1.PriceR\fendingEquity\x12-\n" + + "\x06trades\x18\x04 \x03(\v2\x15.alt.v1.BacktestTradeR\x06trades\x126\n" + + "\tpositions\x18\x05 \x03(\v2\x18.alt.v1.BacktestPositionR\tpositions\"1\n" + "\x18GetBacktestResultRequest\x12\x15\n" + "\x06run_id\x18\x01 \x01(\tR\x05runId\"K\n" + "\x19GetBacktestResultResponse\x12.\n" + @@ -612,7 +777,7 @@ func file_alt_v1_backtest_proto_rawDescGZIP() []byte { } var file_alt_v1_backtest_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_alt_v1_backtest_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_alt_v1_backtest_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_alt_v1_backtest_proto_goTypes = []any{ (BacktestRunStatus)(0), // 0: alt.v1.BacktestRunStatus (*BacktestRunSpec)(nil), // 1: alt.v1.BacktestRunSpec @@ -621,29 +786,38 @@ var file_alt_v1_backtest_proto_goTypes = []any{ (*StartBacktestResponse)(nil), // 4: alt.v1.StartBacktestResponse (*GetBacktestRunRequest)(nil), // 5: alt.v1.GetBacktestRunRequest (*GetBacktestRunResponse)(nil), // 6: alt.v1.GetBacktestRunResponse - (*BacktestResult)(nil), // 7: alt.v1.BacktestResult - (*GetBacktestResultRequest)(nil), // 8: alt.v1.GetBacktestResultRequest - (*GetBacktestResultResponse)(nil), // 9: alt.v1.GetBacktestResultResponse - (Market)(0), // 10: alt.v1.Market - (Timeframe)(0), // 11: alt.v1.Timeframe - (*Price)(nil), // 12: alt.v1.Price + (*BacktestTrade)(nil), // 7: alt.v1.BacktestTrade + (*BacktestPosition)(nil), // 8: alt.v1.BacktestPosition + (*BacktestResult)(nil), // 9: alt.v1.BacktestResult + (*GetBacktestResultRequest)(nil), // 10: alt.v1.GetBacktestResultRequest + (*GetBacktestResultResponse)(nil), // 11: alt.v1.GetBacktestResultResponse + (Market)(0), // 12: alt.v1.Market + (Timeframe)(0), // 13: alt.v1.Timeframe + (*Quantity)(nil), // 14: alt.v1.Quantity + (*Price)(nil), // 15: alt.v1.Price } var file_alt_v1_backtest_proto_depIdxs = []int32{ - 10, // 0: alt.v1.BacktestRunSpec.market:type_name -> alt.v1.Market - 11, // 1: alt.v1.BacktestRunSpec.timeframe:type_name -> alt.v1.Timeframe + 12, // 0: alt.v1.BacktestRunSpec.market:type_name -> alt.v1.Market + 13, // 1: alt.v1.BacktestRunSpec.timeframe:type_name -> alt.v1.Timeframe 1, // 2: alt.v1.BacktestRun.spec:type_name -> alt.v1.BacktestRunSpec 0, // 3: alt.v1.BacktestRun.status:type_name -> alt.v1.BacktestRunStatus 1, // 4: alt.v1.StartBacktestRequest.spec:type_name -> alt.v1.BacktestRunSpec 2, // 5: alt.v1.StartBacktestResponse.run:type_name -> alt.v1.BacktestRun 2, // 6: alt.v1.GetBacktestRunResponse.run:type_name -> alt.v1.BacktestRun - 12, // 7: alt.v1.BacktestResult.starting_cash:type_name -> alt.v1.Price - 12, // 8: alt.v1.BacktestResult.ending_equity:type_name -> alt.v1.Price - 7, // 9: alt.v1.GetBacktestResultResponse.result:type_name -> alt.v1.BacktestResult - 10, // [10:10] is the sub-list for method output_type - 10, // [10:10] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 14, // 7: alt.v1.BacktestTrade.quantity:type_name -> alt.v1.Quantity + 15, // 8: alt.v1.BacktestTrade.price:type_name -> alt.v1.Price + 14, // 9: alt.v1.BacktestPosition.quantity:type_name -> alt.v1.Quantity + 15, // 10: alt.v1.BacktestPosition.last_price:type_name -> alt.v1.Price + 15, // 11: alt.v1.BacktestResult.starting_cash:type_name -> alt.v1.Price + 15, // 12: alt.v1.BacktestResult.ending_equity:type_name -> alt.v1.Price + 7, // 13: alt.v1.BacktestResult.trades:type_name -> alt.v1.BacktestTrade + 8, // 14: alt.v1.BacktestResult.positions:type_name -> alt.v1.BacktestPosition + 9, // 15: alt.v1.GetBacktestResultResponse.result:type_name -> alt.v1.BacktestResult + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_alt_v1_backtest_proto_init() } @@ -659,7 +833,7 @@ func file_alt_v1_backtest_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_alt_v1_backtest_proto_rawDesc), len(file_alt_v1_backtest_proto_rawDesc)), NumEnums: 1, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 0, }, diff --git a/packages/contracts/proto/alt/v1/backtest.proto b/packages/contracts/proto/alt/v1/backtest.proto index aed221e..651f1f3 100644 --- a/packages/contracts/proto/alt/v1/backtest.proto +++ b/packages/contracts/proto/alt/v1/backtest.proto @@ -48,10 +48,26 @@ message GetBacktestRunResponse { BacktestRun run = 1; } +message BacktestTrade { + string instrument_id = 1; + string side = 2; + Quantity quantity = 3; + Price price = 4; + int64 timestamp_unix_ms = 5; +} + +message BacktestPosition { + string instrument_id = 1; + Quantity quantity = 2; + Price last_price = 3; +} + message BacktestResult { string run_id = 1; Price starting_cash = 2; Price ending_equity = 3; + repeated BacktestTrade trades = 4; + repeated BacktestPosition positions = 5; } message GetBacktestResultRequest { diff --git a/packages/domain/backtest/types.go b/packages/domain/backtest/types.go index 5727ee8..46bc8fa 100644 --- a/packages/domain/backtest/types.go +++ b/packages/domain/backtest/types.go @@ -38,10 +38,26 @@ type Run struct { UpdatedAt time.Time } +type TradeSummary struct { + InstrumentID market.InstrumentID + Side OrderSide + Quantity market.Quantity + Price market.Price + Timestamp time.Time +} + +type PositionSummary struct { + InstrumentID market.InstrumentID + Quantity market.Quantity + LastPrice market.Price +} + type Result struct { RunID RunID StartingCash market.Price EndingEquity market.Price + Trades []TradeSummary + Positions []PositionSummary } type Strategy interface { diff --git a/packages/domain/backtest/types_test.go b/packages/domain/backtest/types_test.go index 995f9e1..4003f3a 100644 --- a/packages/domain/backtest/types_test.go +++ b/packages/domain/backtest/types_test.go @@ -2,6 +2,7 @@ package backtest import ( "testing" + "time" "git.toki-labs.com/toki/alt/packages/domain/market" ) @@ -152,3 +153,71 @@ func price(amount string) market.Price { func quantity(amount string) market.Quantity { return market.Quantity{Amount: market.Decimal{Value: amount}} } + +func TestResultCarriesTradeAndPositionSummary(t *testing.T) { + now := time.Now().UTC() + res := Result{ + RunID: RunID("run-123"), + StartingCash: price("1000"), + EndingEquity: price("1500"), + Trades: []TradeSummary{ + { + InstrumentID: market.InstrumentID("KRX:005930"), + Side: OrderSideBuy, + Quantity: quantity("10"), + Price: price("500"), + Timestamp: now, + }, + }, + Positions: []PositionSummary{ + { + InstrumentID: market.InstrumentID("KRX:005930"), + Quantity: quantity("10"), + LastPrice: price("520"), + }, + }, + } + + if res.RunID != RunID("run-123") { + t.Errorf("RunID mismatch: got %v, want run-123", res.RunID) + } + if res.StartingCash.Amount.Value != "1000" { + t.Errorf("StartingCash mismatch: got %v, want 1000", res.StartingCash.Amount.Value) + } + if res.EndingEquity.Amount.Value != "1500" { + t.Errorf("EndingEquity mismatch: got %v, want 1500", res.EndingEquity.Amount.Value) + } + if len(res.Trades) != 1 { + t.Fatalf("Trades length mismatch: got %d, want 1", len(res.Trades)) + } + trade := res.Trades[0] + if trade.InstrumentID != market.InstrumentID("KRX:005930") { + t.Errorf("Trade InstrumentID mismatch: got %v", trade.InstrumentID) + } + if trade.Side != OrderSideBuy { + t.Errorf("Trade Side mismatch: got %v", trade.Side) + } + if trade.Quantity.Amount.Value != "10" { + t.Errorf("Trade Quantity mismatch: got %v", trade.Quantity.Amount.Value) + } + if trade.Price.Amount.Value != "500" { + t.Errorf("Trade Price mismatch: got %v", trade.Price.Amount.Value) + } + if !trade.Timestamp.Equal(now) { + t.Errorf("Trade Timestamp mismatch: got %v, want %v", trade.Timestamp, now) + } + + if len(res.Positions) != 1 { + t.Fatalf("Positions length mismatch: got %d, want 1", len(res.Positions)) + } + pos := res.Positions[0] + if pos.InstrumentID != market.InstrumentID("KRX:005930") { + t.Errorf("Position InstrumentID mismatch: got %v", pos.InstrumentID) + } + if pos.Quantity.Amount.Value != "10" { + t.Errorf("Position Quantity mismatch: got %v", pos.Quantity.Amount.Value) + } + if pos.LastPrice.Amount.Value != "520" { + t.Errorf("Position LastPrice mismatch: got %v", pos.LastPrice.Amount.Value) + } +} diff --git a/services/worker/internal/backtest/engine.go b/services/worker/internal/backtest/engine.go new file mode 100644 index 0000000..145b764 --- /dev/null +++ b/services/worker/internal/backtest/engine.go @@ -0,0 +1,156 @@ +package backtest + +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. +type BarSource interface { + GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) +} + +// StrategyPort resolves strategies for a backtest run. +type StrategyPort interface { + GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) +} + +// Engine implements the jobs.BacktestExecutor interface and runs the backtest strategy loop. +type Engine struct { + barSource BarSource + strategyPort StrategyPort + resultStore storage.BacktestResultStore +} + +// NewEngine creates a new backtest Engine instance. +func NewEngine(barSource BarSource, strategyPort StrategyPort, resultStore storage.BacktestResultStore) *Engine { + return &Engine{ + barSource: barSource, + strategyPort: strategyPort, + resultStore: resultStore, + } +} + +// Execute runs the backtest strategy loop. +func (e *Engine) Execute(ctx context.Context, run backtest.Run) error { + strategy, err := e.strategyPort.GetStrategy(ctx, run.Spec.StrategyID) + if err != nil { + 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) + 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 { + return bars[i].Timestamp.Before(bars[j].Timestamp) + }) + + var startingCash market.Price + if run.Spec.Market == market.MarketUS { + startingCash = market.Price{ + Currency: market.CurrencyUSD, + Amount: market.Decimal{Value: "10000"}, + } + } else { + startingCash = market.Price{ + Currency: market.CurrencyKRW, + Amount: market.Decimal{Value: "10000000"}, + } + } + + portfolio := backtest.NewPortfolioState(startingCash) + var history []market.Bar + var trades []backtest.TradeSummary + + for _, bar := range bars { + input := backtest.StrategyInput{ + Run: run, + Bar: bar, + Portfolio: portfolio, + History: history, + } + + orders, err := strategy.Decide(input) + if err != nil { + return fmt.Errorf("strategy decide failed for bar at %s: %w", bar.Timestamp, err) + } + + for _, order := range orders { + fill := backtest.Fill{ + InstrumentID: order.InstrumentID, + Side: order.Side, + Quantity: order.Quantity, + Price: bar.Close, + } + nextPortfolio, err := portfolio.ApplyFill(fill) + if err != nil { + return fmt.Errorf("failed to apply fill for order %+v: %w", order, err) + } + portfolio = nextPortfolio + + // Record trade + trades = append(trades, backtest.TradeSummary{ + InstrumentID: fill.InstrumentID, + Side: fill.Side, + Quantity: fill.Quantity, + Price: fill.Price, + Timestamp: bar.Timestamp, + }) + } + + // Update mark price of any open position for the current bar + if _, ok := portfolio.Position(bar.InstrumentID); ok { + portfolio, err = portfolio.MarkPrice(bar.InstrumentID, bar.Close) + if err != nil { + return fmt.Errorf("failed to mark price for instrument %s: %w", bar.InstrumentID, err) + } + } + + history = append(history, bar) + } + + // Calculate ending equity + endingEquity, err := portfolio.Equity() + if err != nil { + return fmt.Errorf("failed to calculate ending equity: %w", err) + } + + // Collect positions + var positions []backtest.PositionSummary + for instID, pos := range portfolio.Positions { + positions = append(positions, backtest.PositionSummary{ + InstrumentID: instID, + Quantity: pos.Quantity, + LastPrice: pos.LastPrice, + }) + } + // Sort positions alphabetically by InstrumentID for determinism + sort.Slice(positions, func(i, j int) bool { + return positions[i].InstrumentID < positions[j].InstrumentID + }) + + result := backtest.Result{ + RunID: run.ID, + StartingCash: startingCash, + EndingEquity: endingEquity, + Trades: trades, + Positions: positions, + } + + if e.resultStore != nil { + if err := e.resultStore.UpsertResult(ctx, result); err != nil { + return fmt.Errorf("failed to upsert result: %w", err) + } + } + + return nil +} diff --git a/services/worker/internal/backtest/engine_test.go b/services/worker/internal/backtest/engine_test.go new file mode 100644 index 0000000..b296ca0 --- /dev/null +++ b/services/worker/internal/backtest/engine_test.go @@ -0,0 +1,126 @@ +package backtest + +import ( + "context" + "testing" + "time" + + "git.toki-labs.com/toki/alt/packages/domain/backtest" + "git.toki-labs.com/toki/alt/packages/domain/market" +) + +type mockBarSource struct { + bars []market.Bar +} + +func (m *mockBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) { + return m.bars, nil +} + +type mockStrategy struct { + decideCalls []backtest.StrategyInput +} + +func (m *mockStrategy) ID() backtest.StrategyID { + return "test-strategy" +} + +func (m *mockStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { + m.decideCalls = append(m.decideCalls, input) + + // Buy 2 units of the instrument on the first bar + if len(m.decideCalls) == 1 { + return []backtest.OrderIntent{ + { + InstrumentID: input.Bar.InstrumentID, + Side: backtest.OrderSideBuy, + Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}}, + }, + }, nil + } + return nil, nil +} + +type mockStrategyPort struct { + strategy backtest.Strategy +} + +func (m *mockStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) { + return m.strategy, nil +} + +func TestEngineCallsStrategyForBars(t *testing.T) { + // Create fixture bars in unsorted order to verify sorting + bar1 := market.Bar{ + InstrumentID: market.InstrumentID("KRX:005930"), + 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"}}, + } + bar2 := market.Bar{ + InstrumentID: market.InstrumentID("KRX:005930"), + 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"}}, + } + + barSource := &mockBarSource{bars: []market.Bar{bar2, bar1}} // unsorted + strat := &mockStrategy{} + strategyPort := &mockStrategyPort{strategy: strat} + + engine := NewEngine(barSource, strategyPort, nil) + + run := backtest.Run{ + ID: "run-1", + 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), + }, + } + + err := engine.Execute(context.Background(), run) + if err != nil { + t.Fatalf("Engine execution failed: %v", err) + } + + if len(strat.decideCalls) != 2 { + t.Fatalf("expected 2 strategy Decide calls, got %d", len(strat.decideCalls)) + } + + // Verify chronological order: first call should have bar1 (May 1), second should have bar2 (May 2) + firstCall := strat.decideCalls[0] + if !firstCall.Bar.Timestamp.Equal(bar1.Timestamp) { + t.Errorf("expected first bar to be May 1, got %s", firstCall.Bar.Timestamp) + } + // Initial portfolio cash should be 10000000 (KRW default) + if firstCall.Portfolio.Cash.Amount.Value != "10000000" { + t.Errorf("expected starting cash 10000000, got %s", firstCall.Portfolio.Cash.Amount.Value) + } + + secondCall := strat.decideCalls[1] + if !secondCall.Bar.Timestamp.Equal(bar2.Timestamp) { + t.Errorf("expected second bar to be May 2, got %s", secondCall.Bar.Timestamp) + } + // History in second call should contain bar1 + if len(secondCall.History) != 1 { + t.Fatalf("expected history size 1 in second call, got %d", len(secondCall.History)) + } + if !secondCall.History[0].Timestamp.Equal(bar1.Timestamp) { + t.Errorf("expected history[0] to be May 1 bar, got %s", secondCall.History[0].Timestamp) + } + // Portfolio cash in second call should reflect the purchase: 10000000 - 2 * 1000 = 9998000 + if secondCall.Portfolio.Cash.Amount.Value != "9998000" { + t.Errorf("expected cash after buy to be 9998000, got %s", secondCall.Portfolio.Cash.Amount.Value) + } + // Position quantity should be 2 + pos, ok := secondCall.Portfolio.Position(market.InstrumentID("KRX:005930")) + if !ok { + t.Fatal("expected position in second call portfolio") + } + if pos.Quantity.Amount.Value != "2" { + t.Errorf("expected position quantity 2, got %s", pos.Quantity.Amount.Value) + } +} diff --git a/services/worker/internal/backtest/fixture_test.go b/services/worker/internal/backtest/fixture_test.go new file mode 100644 index 0000000..4b3ae95 --- /dev/null +++ b/services/worker/internal/backtest/fixture_test.go @@ -0,0 +1,260 @@ +package backtest + +import ( + "context" + "reflect" + "sync" + "testing" + "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" +) + +type inMemoryResultStore struct { + mu sync.RWMutex + results map[backtest.RunID]backtest.Result +} + +func newInMemoryResultStore() *inMemoryResultStore { + return &inMemoryResultStore{ + results: make(map[backtest.RunID]backtest.Result), + } +} + +func (m *inMemoryResultStore) UpsertResult(ctx context.Context, result backtest.Result) error { + m.mu.Lock() + defer m.mu.Unlock() + m.results[result.RunID] = result + return nil +} + +func (m *inMemoryResultStore) GetResult(ctx context.Context, id backtest.RunID) (backtest.Result, error) { + m.mu.RLock() + defer m.mu.RUnlock() + res, ok := m.results[id] + if !ok { + return backtest.Result{}, storage.ErrResultNotFound + } + return res, nil +} + +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) { + return m.bars, nil +} + +type inMemoryStrategyPort struct { + strategy backtest.Strategy +} + +func (m *inMemoryStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) { + return m.strategy, nil +} + +type deterministicStrategy struct { + id backtest.StrategyID +} + +func (s *deterministicStrategy) ID() backtest.StrategyID { + return s.id +} + +func (s *deterministicStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) { + // Buy 2 units of the instrument on the first bar (May 1), + // and sell 1 unit on the second bar (May 2). + day := input.Bar.Timestamp.Day() + if day == 1 { + return []backtest.OrderIntent{ + { + InstrumentID: input.Bar.InstrumentID, + Side: backtest.OrderSideBuy, + Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}}, + }, + }, nil + } else if day == 2 { + return []backtest.OrderIntent{ + { + InstrumentID: input.Bar.InstrumentID, + Side: backtest.OrderSideSell, + Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, + }, + }, nil + } + return nil, nil +} + +func TestEngineProducesDeterministicResultFromFixtureBars(t *testing.T) { + instID := market.InstrumentID("KRX:005930") + bar1 := market.Bar{ + InstrumentID: instID, + 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"}}, + } + bar2 := market.Bar{ + InstrumentID: instID, + 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"}}, + } + bar3 := market.Bar{ + InstrumentID: instID, + 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"}}, + } + + barSource := &inMemoryBarSource{bars: []market.Bar{bar3, bar1, bar2}} // unsorted + strat := &deterministicStrategy{id: "det-strat"} + strategyPort := &inMemoryStrategyPort{strategy: strat} + resultStore := newInMemoryResultStore() + + engine := NewEngine(barSource, strategyPort, resultStore) + + run := backtest.Run{ + ID: "run-deterministic", + Spec: backtest.RunSpec{ + StrategyID: "det-strat", + 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), + }, + } + + // First execution + err := engine.Execute(context.Background(), run) + if err != nil { + t.Fatalf("first execution failed: %v", err) + } + res1, err := resultStore.GetResult(context.Background(), run.ID) + if err != nil { + t.Fatalf("failed to get first result: %v", err) + } + + // Clear the store to make sure upsert actually writes the second result + resultStore.mu.Lock() + delete(resultStore.results, run.ID) + resultStore.mu.Unlock() + + // Second execution + err = engine.Execute(context.Background(), run) + if err != nil { + t.Fatalf("second execution failed: %v", err) + } + res2, err := resultStore.GetResult(context.Background(), run.ID) + if err != nil { + t.Fatalf("failed to get second result: %v", err) + } + + // Verify deep equality + if !reflect.DeepEqual(res1, res2) { + t.Errorf("results are not identical.\nResult 1: %+v\nResult 2: %+v", res1, res2) + } +} + +func TestEngineStoresAndQueriesFixtureResult(t *testing.T) { + instID := market.InstrumentID("KRX:005930") + bar1 := market.Bar{ + InstrumentID: instID, + 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"}}, + } + bar2 := market.Bar{ + InstrumentID: instID, + 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"}}, + } + bar3 := market.Bar{ + InstrumentID: instID, + 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"}}, + } + + barSource := &inMemoryBarSource{bars: []market.Bar{bar1, bar2, bar3}} + strat := &deterministicStrategy{id: "det-strat"} + strategyPort := &inMemoryStrategyPort{strategy: strat} + resultStore := newInMemoryResultStore() + + engine := NewEngine(barSource, strategyPort, resultStore) + + run := backtest.Run{ + ID: "run-query", + Spec: backtest.RunSpec{ + StrategyID: "det-strat", + 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), + }, + } + + err := engine.Execute(context.Background(), run) + if err != nil { + t.Fatalf("execution failed: %v", err) + } + + res, err := resultStore.GetResult(context.Background(), run.ID) + if err != nil { + t.Fatalf("failed to get result: %v", err) + } + + // Let's manually calculate the expected values: + // Starting cash = 10,000,000 KRW + // + // Day 1: Buy 2 units at 1000 KRW + // Cash = 10,000,000 - 2,000 = 9,998,000 KRW + // Position = 2 units, mark price = 1000 KRW + // + // Day 2: Sell 1 unit at 1100 KRW + // Cash = 9,998,000 + 1,100 = 9,999,100 KRW + // Position = 1 unit, mark price = 1100 KRW + // + // Day 3: No trades. + // Position = 1 unit, marked at last price = 1200 KRW + // Final Equity = Cash (9,999,100) + 1 * 1200 = 10,000,300 KRW + // + // Expected trades: + // 1. Buy 2 units at 1000 KRW on May 1 + // 2. Sell 1 unit at 1100 KRW on May 2 + // + // Expected positions: + // 1. 1 unit of KRX:005930 at last price 1200 KRW + + if res.StartingCash.Amount.Value != "10000000" { + t.Errorf("expected starting cash 10000000, got %s", res.StartingCash.Amount.Value) + } + if res.EndingEquity.Amount.Value != "10000300" { + t.Errorf("expected ending equity 10000300, got %s", res.EndingEquity.Amount.Value) + } + + // Verify Trades + if len(res.Trades) != 2 { + t.Fatalf("expected 2 trades, got %d", len(res.Trades)) + } + trade1 := res.Trades[0] + if trade1.Side != backtest.OrderSideBuy || trade1.Quantity.Amount.Value != "2" || trade1.Price.Amount.Value != "1000" { + t.Errorf("unexpected trade 1: %+v", trade1) + } + trade2 := res.Trades[1] + if trade2.Side != backtest.OrderSideSell || trade2.Quantity.Amount.Value != "1" || trade2.Price.Amount.Value != "1100" { + t.Errorf("unexpected trade 2: %+v", trade2) + } + + // Verify Positions + if len(res.Positions) != 1 { + t.Fatalf("expected 1 position, got %d", len(res.Positions)) + } + pos := res.Positions[0] + if pos.InstrumentID != instID || pos.Quantity.Amount.Value != "1" || pos.LastPrice.Amount.Value != "1200" { + t.Errorf("unexpected position: %+v", pos) + } +} diff --git a/services/worker/internal/jobs/backtest_jobs.go b/services/worker/internal/jobs/backtest_jobs.go new file mode 100644 index 0000000..4e389e7 --- /dev/null +++ b/services/worker/internal/jobs/backtest_jobs.go @@ -0,0 +1,160 @@ +package jobs + +import ( + "context" + "encoding/json" + "fmt" + "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" +) + +// BacktestExecutor defines the interface for running backtests. +type BacktestExecutor interface { + Execute(ctx context.Context, run backtest.Run) error +} + +// 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"` +} + +// parsePayloadDate parses standard date strings supported by the payload. +func parsePayloadDate(value string) (time.Time, error) { + for _, layout := range []string{time.RFC3339, "2006-01-02", "20060102"} { + if t, err := time.Parse(layout, value); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("invalid date format %q", value) +} + +// DecodeRunBacktestPayload decodes and validates a KindRunBacktest payload. +func DecodeRunBacktestPayload(raw json.RawMessage) (RunBacktestPayload, error) { + var p RunBacktestPayload + if err := json.Unmarshal(raw, &p); err != nil { + return RunBacktestPayload{}, fmt.Errorf("decode run backtest payload: %w", err) + } + if p.RunID == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: run_id is required") + } + if p.StrategyID == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: strategy_id is required") + } + if p.Market == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: market is required") + } + if p.Timeframe == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: timeframe is required") + } + if p.From == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: from is required") + } + if p.To == "" { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: to is required") + } + + from, err := parsePayloadDate(p.From) + if err != nil { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: invalid from date: %w", err) + } + to, err := parsePayloadDate(p.To) + if err != nil { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: invalid to date: %w", err) + } + + if from.After(to) { + return RunBacktestPayload{}, fmt.Errorf("run backtest payload: from date cannot be after to date") + } + + return p, nil +} + +// RegisterRunBacktestHandler registers the concrete KindRunBacktest job handler. +// It manages transition of run states: Decodes payload -> status:running -> call executor -> status:succeeded/failed. +func RegisterRunBacktestHandler( + runner *Runner, + store storage.BacktestRunStore, + executor BacktestExecutor, + now func() time.Time, +) { + runner.Register(KindRunBacktest, func(ctx context.Context, payload json.RawMessage) error { + p, err := DecodeRunBacktestPayload(payload) + if err != nil { + return err + } + + runID := backtest.RunID(p.RunID) + fromTime, _ := parsePayloadDate(p.From) + toTime, _ := parsePayloadDate(p.To) + + spec := backtest.RunSpec{ + StrategyID: backtest.StrategyID(p.StrategyID), + Market: market.Market(p.Market), + Timeframe: market.Timeframe(p.Timeframe), + From: fromTime, + To: toTime, + } + + currentTime := now().UTC() + run, err := store.GetRun(ctx, runID) + if err != nil { + if err == storage.ErrRunNotFound { + // Create new run in pending status first + run = backtest.Run{ + ID: runID, + Spec: spec, + Status: backtest.RunStatusPending, + CreatedAt: currentTime, + UpdatedAt: currentTime, + } + if err := store.UpsertRun(ctx, run); err != nil { + return fmt.Errorf("failed to transition run to pending: %w", err) + } + + // Transition to running + run.Status = backtest.RunStatusRunning + run.UpdatedAt = currentTime + } else { + // Other store errors prevent execution and are returned immediately + return fmt.Errorf("failed to get run: %w", err) + } + } else { + run.Spec = spec + run.Status = backtest.RunStatusRunning + run.UpdatedAt = currentTime + } + + // Record running status + if err := store.UpsertRun(ctx, run); err != nil { + return fmt.Errorf("failed to transition run to running: %w", err) + } + + // Execute + execErr := executor.Execute(ctx, run) + + // Record final status + run.UpdatedAt = now().UTC() + if execErr != nil { + run.Status = backtest.RunStatusFailed + if upsertErr := store.UpsertRun(ctx, run); upsertErr != nil { + return fmt.Errorf("failed to transition run to failed (original error: %v): %w", execErr, upsertErr) + } + return execErr + } + + run.Status = backtest.RunStatusSucceeded + if upsertErr := store.UpsertRun(ctx, run); upsertErr != nil { + return fmt.Errorf("failed to transition run to succeeded: %w", upsertErr) + } + + return nil + }) +} diff --git a/services/worker/internal/jobs/backtest_jobs_test.go b/services/worker/internal/jobs/backtest_jobs_test.go new file mode 100644 index 0000000..a2729ba --- /dev/null +++ b/services/worker/internal/jobs/backtest_jobs_test.go @@ -0,0 +1,321 @@ +package jobs + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "git.toki-labs.com/toki/alt/packages/domain/backtest" + "git.toki-labs.com/toki/alt/services/worker/internal/storage" +) + +type stubBacktestRunStore struct { + runs map[backtest.RunID]backtest.Run + upsertCalls []backtest.Run + getError error +} + +func (s *stubBacktestRunStore) UpsertRun(ctx context.Context, run backtest.Run) error { + s.runs[run.ID] = run + s.upsertCalls = append(s.upsertCalls, run) + return nil +} + +func (s *stubBacktestRunStore) GetRun(ctx context.Context, id backtest.RunID) (backtest.Run, error) { + if s.getError != nil { + return backtest.Run{}, s.getError + } + run, ok := s.runs[id] + if !ok { + return backtest.Run{}, storage.ErrRunNotFound + } + return run, nil +} + +type stubBacktestExecutor struct { + called bool + run backtest.Run + err error +} + +func (s *stubBacktestExecutor) Execute(ctx context.Context, run backtest.Run) error { + s.called = true + s.run = run + return s.err +} + +func TestRegisterRunBacktestHandlerTransitionsSucceeded(t *testing.T) { + runner := NewRunner() + store := &stubBacktestRunStore{runs: make(map[backtest.RunID]backtest.Run)} + executor := &stubBacktestExecutor{} + + fixedTime := time.Date(2026, 5, 30, 11, 0, 0, 0, time.UTC) + nowFunc := func() time.Time { return fixedTime } + + RegisterRunBacktestHandler(runner, store, executor, nowFunc) + + payload := json.RawMessage(`{ + "run_id": "run-123", + "strategy_id": "strat-abc", + "market": "KR", + "timeframe": "1d", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-15T00:00:00Z" + }`) + + job := Job{ + ID: "job-1", + Kind: KindRunBacktest, + Payload: payload, + } + + err := runner.Execute(context.Background(), job) + if err != nil { + t.Fatalf("unexpected error executing job: %v", err) + } + + if !executor.called { + t.Fatal("expected executor to be called, but it was not") + } + + // Verify transitions (new run: pending -> running -> succeeded) + if len(store.upsertCalls) != 3 { + t.Fatalf("expected 3 UpsertRun calls, got %d", len(store.upsertCalls)) + } + + if store.upsertCalls[0].Status != backtest.RunStatusPending { + t.Errorf("expected first transition to be pending, got %s", store.upsertCalls[0].Status) + } + + if store.upsertCalls[1].Status != backtest.RunStatusRunning { + t.Errorf("expected second transition to be running, got %s", store.upsertCalls[1].Status) + } + + if store.upsertCalls[2].Status != backtest.RunStatusSucceeded { + t.Errorf("expected third transition to be succeeded, got %s", store.upsertCalls[2].Status) + } +} + +func TestRegisterRunBacktestHandlerTransitionsFailed(t *testing.T) { + runner := NewRunner() + store := &stubBacktestRunStore{runs: make(map[backtest.RunID]backtest.Run)} + executor := &stubBacktestExecutor{err: errors.New("execution failed")} + + fixedTime := time.Date(2026, 5, 30, 11, 0, 0, 0, time.UTC) + nowFunc := func() time.Time { return fixedTime } + + RegisterRunBacktestHandler(runner, store, executor, nowFunc) + + payload := json.RawMessage(`{ + "run_id": "run-123", + "strategy_id": "strat-abc", + "market": "KR", + "timeframe": "1d", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-15T00:00:00Z" + }`) + + job := Job{ + ID: "job-1", + Kind: KindRunBacktest, + Payload: payload, + } + + err := runner.Execute(context.Background(), job) + if err == nil { + t.Fatal("expected executor error, got nil") + } + + if !executor.called { + t.Fatal("expected executor to be called, but it was not") + } + + // Verify transitions (new run: pending -> running -> failed) + if len(store.upsertCalls) != 3 { + t.Fatalf("expected 3 UpsertRun calls, got %d", len(store.upsertCalls)) + } + + if store.upsertCalls[0].Status != backtest.RunStatusPending { + t.Errorf("expected first transition to be pending, got %s", store.upsertCalls[0].Status) + } + + if store.upsertCalls[1].Status != backtest.RunStatusRunning { + t.Errorf("expected second transition to be running, got %s", store.upsertCalls[1].Status) + } + + if store.upsertCalls[2].Status != backtest.RunStatusFailed { + t.Errorf("expected third transition to be failed, got %s", store.upsertCalls[2].Status) + } +} + +func TestRegisterRunBacktestHandlerExistsTransitionsSucceeded(t *testing.T) { + runner := NewRunner() + store := &stubBacktestRunStore{runs: make(map[backtest.RunID]backtest.Run)} + executor := &stubBacktestExecutor{} + + fixedTime := time.Date(2026, 5, 30, 11, 0, 0, 0, time.UTC) + nowFunc := func() time.Time { return fixedTime } + + // Seed the run already in the store + runID := backtest.RunID("run-123") + store.runs[runID] = backtest.Run{ + ID: runID, + Status: backtest.RunStatusPending, + CreatedAt: fixedTime.Add(-time.Hour), + UpdatedAt: fixedTime.Add(-time.Hour), + } + + RegisterRunBacktestHandler(runner, store, executor, nowFunc) + + payload := json.RawMessage(`{ + "run_id": "run-123", + "strategy_id": "strat-abc", + "market": "KR", + "timeframe": "1d", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-15T00:00:00Z" + }`) + + job := Job{ + ID: "job-1", + Kind: KindRunBacktest, + Payload: payload, + } + + err := runner.Execute(context.Background(), job) + if err != nil { + t.Fatalf("unexpected error executing job: %v", err) + } + + if !executor.called { + t.Fatal("expected executor to be called, but it was not") + } + + // Verify transitions (existing run: running -> succeeded) + if len(store.upsertCalls) != 2 { + t.Fatalf("expected 2 UpsertRun calls, got %d", len(store.upsertCalls)) + } + + if store.upsertCalls[0].Status != backtest.RunStatusRunning { + t.Errorf("expected first transition to be running, got %s", store.upsertCalls[0].Status) + } + + if store.upsertCalls[1].Status != backtest.RunStatusSucceeded { + t.Errorf("expected second transition to be succeeded, got %s", store.upsertCalls[1].Status) + } + + // Check CreatedAt was preserved + if !store.runs[runID].CreatedAt.Equal(fixedTime.Add(-time.Hour)) { + t.Errorf("expected CreatedAt to be preserved as %v, got %v", fixedTime.Add(-time.Hour), store.runs[runID].CreatedAt) + } +} + +func TestRegisterRunBacktestHandlerStoreError(t *testing.T) { + runner := NewRunner() + store := &stubBacktestRunStore{ + runs: make(map[backtest.RunID]backtest.Run), + getError: errors.New("hard db error"), + } + executor := &stubBacktestExecutor{} + + fixedTime := time.Date(2026, 5, 30, 11, 0, 0, 0, time.UTC) + nowFunc := func() time.Time { return fixedTime } + + RegisterRunBacktestHandler(runner, store, executor, nowFunc) + + payload := json.RawMessage(`{ + "run_id": "run-123", + "strategy_id": "strat-abc", + "market": "KR", + "timeframe": "1d", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-15T00:00:00Z" + }`) + + job := Job{ + ID: "job-1", + Kind: KindRunBacktest, + Payload: payload, + } + + err := runner.Execute(context.Background(), job) + if err == nil { + t.Fatal("expected error from hard DB error, got nil") + } + + if executor.called { + t.Fatal("expected executor not to be called on store error") + } + + if len(store.upsertCalls) != 0 { + t.Fatalf("expected 0 UpsertRun calls on store error, got %d", len(store.upsertCalls)) + } +} + +func TestRegisterRunBacktestHandlerOverridesBuiltinPlaceholder(t *testing.T) { + runner := NewRunner() + RegisterBuiltins(runner) + + store := &stubBacktestRunStore{runs: make(map[backtest.RunID]backtest.Run)} + executor := &stubBacktestExecutor{} + + fixedTime := time.Date(2026, 5, 30, 11, 0, 0, 0, time.UTC) + nowFunc := func() time.Time { return fixedTime } + + RegisterRunBacktestHandler(runner, store, executor, nowFunc) + + // Ensure length remains 3 (overrode KindRunBacktest rather than adding a new kind) + if runner.Len() != 3 { + t.Errorf("expected runner length to remain 3, got %d", runner.Len()) + } + + payload := json.RawMessage(`{ + "run_id": "run-123", + "strategy_id": "strat-abc", + "market": "KR", + "timeframe": "1d", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-15T00:00:00Z" + }`) + + job := Job{ + ID: "job-1", + Kind: KindRunBacktest, + Payload: payload, + } + + err := runner.Execute(context.Background(), job) + if err != nil { + t.Fatalf("unexpected error executing job: %v", err) + } + + if !executor.called { + t.Fatal("expected live executor to be called (placeholder overrode), but it was not") + } +} + +func TestDecodeRunBacktestPayloadRejectsMissingFields(t *testing.T) { + tests := []struct { + name string + payload string + }{ + {"missing run_id", `{"strategy_id":"strat-abc","market":"KR","timeframe":"1d","from":"20260501","to":"20260515"}`}, + {"missing strategy_id", `{"run_id":"run-123","market":"KR","timeframe":"1d","from":"20260501","to":"20260515"}`}, + {"missing market", `{"run_id":"run-123","strategy_id":"strat-abc","timeframe":"1d","from":"20260501","to":"20260515"}`}, + {"missing timeframe", `{"run_id":"run-123","strategy_id":"strat-abc","market":"KR","from":"20260501","to":"20260515"}`}, + {"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"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := DecodeRunBacktestPayload(json.RawMessage(tt.payload)) + if err == nil { + t.Error("expected decoder error, got nil") + } + }) + } +} diff --git a/services/worker/internal/storage/ports.go b/services/worker/internal/storage/ports.go index c30291f..0d8f22d 100644 --- a/services/worker/internal/storage/ports.go +++ b/services/worker/internal/storage/ports.go @@ -2,12 +2,16 @@ package storage import ( "context" + "errors" "time" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" ) +var ErrRunNotFound = errors.New("backtest run not found") +var ErrResultNotFound = errors.New("backtest result not found") + type InstrumentStore interface { UpsertInstrument(ctx context.Context, inst market.Instrument) error GetInstrument(ctx context.Context, id market.InstrumentID) (market.Instrument, error) @@ -23,3 +27,8 @@ type BacktestRunStore interface { UpsertRun(ctx context.Context, run backtest.Run) error GetRun(ctx context.Context, id backtest.RunID) (backtest.Run, error) } + +type BacktestResultStore interface { + UpsertResult(ctx context.Context, result backtest.Result) error + GetResult(ctx context.Context, id backtest.RunID) (backtest.Result, error) +} diff --git a/services/worker/internal/storage/postgres/mapping.go b/services/worker/internal/storage/postgres/mapping.go index 21738ee..5d45784 100644 --- a/services/worker/internal/storage/postgres/mapping.go +++ b/services/worker/internal/storage/postgres/mapping.go @@ -202,3 +202,83 @@ func mapRowToRun(row sqlc.BacktestRun) (backtest.Run, error) { UpdatedAt: row.UpdatedAt.Time, }, nil } + +func mapResultToParams(res backtest.Result) (sqlc.UpsertResultParams, error) { + startingCashAmount, err := stringToNumeric(res.StartingCash.Amount.Value) + if err != nil { + return sqlc.UpsertResultParams{}, fmt.Errorf("invalid starting cash amount: %w", err) + } + endingEquityAmount, err := stringToNumeric(res.EndingEquity.Amount.Value) + if err != nil { + return sqlc.UpsertResultParams{}, fmt.Errorf("invalid ending equity amount: %w", err) + } + + var tradesBytes []byte + if res.Trades == nil { + tradesBytes = []byte("[]") + } else { + tradesBytes, err = json.Marshal(res.Trades) + if err != nil { + return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal trades: %w", err) + } + } + + var positionsBytes []byte + if res.Positions == nil { + positionsBytes = []byte("[]") + } else { + positionsBytes, err = json.Marshal(res.Positions) + if err != nil { + return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal positions: %w", err) + } + } + + return sqlc.UpsertResultParams{ + RunID: string(res.RunID), + StartingCashCurrency: string(res.StartingCash.Currency), + StartingCashAmount: startingCashAmount, + EndingEquityCurrency: string(res.EndingEquity.Currency), + EndingEquityAmount: endingEquityAmount, + Trades: tradesBytes, + Positions: positionsBytes, + }, nil +} + +func mapRowToResult(row sqlc.BacktestResult) (backtest.Result, error) { + startingCashAmountStr, err := numericToString(row.StartingCashAmount) + if err != nil { + return backtest.Result{}, fmt.Errorf("failed to parse starting cash amount: %w", err) + } + endingEquityAmountStr, err := numericToString(row.EndingEquityAmount) + if err != nil { + return backtest.Result{}, fmt.Errorf("failed to parse ending equity amount: %w", err) + } + + var trades []backtest.TradeSummary + if len(row.Trades) > 0 { + if err := json.Unmarshal(row.Trades, &trades); err != nil { + return backtest.Result{}, fmt.Errorf("failed to unmarshal trades: %w", err) + } + } + + var positions []backtest.PositionSummary + if len(row.Positions) > 0 { + if err := json.Unmarshal(row.Positions, &positions); err != nil { + return backtest.Result{}, fmt.Errorf("failed to unmarshal positions: %w", err) + } + } + + return backtest.Result{ + RunID: backtest.RunID(row.RunID), + StartingCash: market.Price{ + Currency: market.Currency(row.StartingCashCurrency), + Amount: market.Decimal{Value: startingCashAmountStr}, + }, + EndingEquity: market.Price{ + Currency: market.Currency(row.EndingEquityCurrency), + Amount: market.Decimal{Value: endingEquityAmountStr}, + }, + Trades: trades, + Positions: positions, + }, nil +} diff --git a/services/worker/internal/storage/postgres/mapping_test.go b/services/worker/internal/storage/postgres/mapping_test.go index 06ef349..2047c85 100644 --- a/services/worker/internal/storage/postgres/mapping_test.go +++ b/services/worker/internal/storage/postgres/mapping_test.go @@ -116,3 +116,106 @@ func TestMarketBarMappingRejectsInvalidDecimal(t *testing.T) { t.Fatal("expected error when mapping bar with invalid volume decimal, got nil") } } + +func TestBacktestResultMappingRoundTrip(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + res := backtest.Result{ + RunID: backtest.RunID("run-123"), + StartingCash: market.Price{ + Currency: market.CurrencyKRW, + Amount: market.Decimal{Value: "100000000"}, + }, + EndingEquity: market.Price{ + Currency: market.CurrencyKRW, + Amount: market.Decimal{Value: "123456789.12"}, + }, + Trades: []backtest.TradeSummary{ + { + InstrumentID: market.InstrumentID("KRX:005930"), + Side: backtest.OrderSideBuy, + Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}}, + Price: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "50000"}}, + Timestamp: now.Add(-1 * time.Hour), + }, + }, + Positions: []backtest.PositionSummary{ + { + InstrumentID: market.InstrumentID("KRX:005930"), + Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}}, + LastPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "52000"}}, + }, + }, + } + + params, err := mapResultToParams(res) + if err != nil { + t.Fatalf("failed to map result to params: %v", err) + } + + row := sqlc.BacktestResult{ + RunID: params.RunID, + StartingCashCurrency: params.StartingCashCurrency, + StartingCashAmount: params.StartingCashAmount, + EndingEquityCurrency: params.EndingEquityCurrency, + EndingEquityAmount: params.EndingEquityAmount, + Trades: params.Trades, + Positions: params.Positions, + } + + mappedBack, err := mapRowToResult(row) + if err != nil { + t.Fatalf("failed to map row back to result: %v", err) + } + + if mappedBack.RunID != res.RunID { + t.Errorf("RunID mismatch: got %v, want %v", mappedBack.RunID, res.RunID) + } + if mappedBack.StartingCash.Currency != res.StartingCash.Currency || mappedBack.StartingCash.Amount.Value != res.StartingCash.Amount.Value { + t.Errorf("StartingCash mismatch: got %v, want %v", mappedBack.StartingCash, res.StartingCash) + } + if mappedBack.EndingEquity.Currency != res.EndingEquity.Currency || mappedBack.EndingEquity.Amount.Value != res.EndingEquity.Amount.Value { + t.Errorf("EndingEquity mismatch: got %v, want %v", mappedBack.EndingEquity, res.EndingEquity) + } + if len(mappedBack.Trades) != len(res.Trades) { + t.Fatalf("Trades length mismatch: got %d, want %d", len(mappedBack.Trades), len(res.Trades)) + } + if mappedBack.Trades[0].InstrumentID != res.Trades[0].InstrumentID || mappedBack.Trades[0].Side != res.Trades[0].Side || mappedBack.Trades[0].Quantity.Amount.Value != res.Trades[0].Quantity.Amount.Value { + t.Errorf("Trade mismatch: got %v, want %v", mappedBack.Trades[0], res.Trades[0]) + } + if !mappedBack.Trades[0].Timestamp.Equal(res.Trades[0].Timestamp) { + t.Errorf("Trade timestamp mismatch: got %v, want %v", mappedBack.Trades[0].Timestamp, res.Trades[0].Timestamp) + } + + if len(mappedBack.Positions) != len(res.Positions) { + t.Fatalf("Positions length mismatch: got %d, want %d", len(mappedBack.Positions), len(res.Positions)) + } + if mappedBack.Positions[0].InstrumentID != res.Positions[0].InstrumentID || mappedBack.Positions[0].Quantity.Amount.Value != res.Positions[0].Quantity.Amount.Value { + t.Errorf("Position mismatch: got %v, want %v", mappedBack.Positions[0], res.Positions[0]) + } +} + +func TestBacktestResultMappingRejectsInvalidDecimal(t *testing.T) { + res := backtest.Result{ + RunID: backtest.RunID("run-123"), + StartingCash: market.Price{ + Currency: market.CurrencyKRW, + Amount: market.Decimal{Value: "invalid"}, + }, + EndingEquity: market.Price{ + Currency: market.CurrencyKRW, + Amount: market.Decimal{Value: "12345"}, + }, + } + + _, err := mapResultToParams(res) + if err == nil { + t.Fatal("expected error when mapping result with invalid starting cash decimal, got nil") + } + + res.StartingCash.Amount.Value = "100" + res.EndingEquity.Amount.Value = "invalid" + _, err = mapResultToParams(res) + if err == nil { + t.Fatal("expected error when mapping result with invalid ending equity decimal, got nil") + } +} diff --git a/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.down.sql b/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.down.sql index 3775348..c45d9ad 100644 --- a/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.down.sql +++ b/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.down.sql @@ -1,3 +1,4 @@ +DROP TABLE IF EXISTS backtest_results; DROP TABLE IF EXISTS backtest_runs; DROP TABLE IF EXISTS bars; DROP TABLE IF EXISTS instruments; diff --git a/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql b/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql index af11f07..8ada724 100644 --- a/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql +++ b/services/worker/internal/storage/postgres/migrations/000001_worker_backbone.up.sql @@ -31,3 +31,13 @@ CREATE TABLE IF NOT EXISTS backtest_runs ( created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); + +CREATE TABLE IF NOT EXISTS backtest_results ( + run_id TEXT PRIMARY KEY REFERENCES backtest_runs(id) ON DELETE CASCADE, + starting_cash_currency TEXT NOT NULL, + starting_cash_amount NUMERIC NOT NULL, + ending_equity_currency TEXT NOT NULL, + ending_equity_amount NUMERIC NOT NULL, + trades JSONB NOT NULL, + positions JSONB NOT NULL +); diff --git a/services/worker/internal/storage/postgres/queries/queries.sql b/services/worker/internal/storage/postgres/queries/queries.sql index 8b4062a..d44db1a 100644 --- a/services/worker/internal/storage/postgres/queries/queries.sql +++ b/services/worker/internal/storage/postgres/queries/queries.sql @@ -51,3 +51,19 @@ ON CONFLICT (id) DO UPDATE SET SELECT id, strategy_id, market, timeframe, from_time, to_time, status, created_at, updated_at FROM backtest_runs WHERE id = $1; + +-- name: UpsertResult :exec +INSERT INTO backtest_results (run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (run_id) DO UPDATE SET + starting_cash_currency = EXCLUDED.starting_cash_currency, + starting_cash_amount = EXCLUDED.starting_cash_amount, + ending_equity_currency = EXCLUDED.ending_equity_currency, + ending_equity_amount = EXCLUDED.ending_equity_amount, + trades = EXCLUDED.trades, + positions = EXCLUDED.positions; + +-- name: GetResult :one +SELECT run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions +FROM backtest_results +WHERE run_id = $1; diff --git a/services/worker/internal/storage/postgres/sqlc/models.go b/services/worker/internal/storage/postgres/sqlc/models.go index 25d18e7..564cb82 100644 --- a/services/worker/internal/storage/postgres/sqlc/models.go +++ b/services/worker/internal/storage/postgres/sqlc/models.go @@ -8,6 +8,16 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type BacktestResult struct { + RunID string + StartingCashCurrency string + StartingCashAmount pgtype.Numeric + EndingEquityCurrency string + EndingEquityAmount pgtype.Numeric + Trades []byte + Positions []byte +} + type BacktestRun struct { ID string StrategyID string diff --git a/services/worker/internal/storage/postgres/sqlc/queries.sql.go b/services/worker/internal/storage/postgres/sqlc/queries.sql.go index a549009..ec2b744 100644 --- a/services/worker/internal/storage/postgres/sqlc/queries.sql.go +++ b/services/worker/internal/storage/postgres/sqlc/queries.sql.go @@ -80,6 +80,27 @@ func (q *Queries) GetInstrument(ctx context.Context, id string) (Instrument, err return i, err } +const getResult = `-- name: GetResult :one +SELECT run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions +FROM backtest_results +WHERE run_id = $1 +` + +func (q *Queries) GetResult(ctx context.Context, runID string) (BacktestResult, error) { + row := q.db.QueryRow(ctx, getResult, runID) + var i BacktestResult + err := row.Scan( + &i.RunID, + &i.StartingCashCurrency, + &i.StartingCashAmount, + &i.EndingEquityCurrency, + &i.EndingEquityAmount, + &i.Trades, + &i.Positions, + ) + return i, err +} + const getRun = `-- name: GetRun :one SELECT id, strategy_id, market, timeframe, from_time, to_time, status, created_at, updated_at FROM backtest_runs @@ -207,6 +228,41 @@ func (q *Queries) UpsertInstrument(ctx context.Context, arg UpsertInstrumentPara return err } +const upsertResult = `-- name: UpsertResult :exec +INSERT INTO backtest_results (run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (run_id) DO UPDATE SET + starting_cash_currency = EXCLUDED.starting_cash_currency, + starting_cash_amount = EXCLUDED.starting_cash_amount, + ending_equity_currency = EXCLUDED.ending_equity_currency, + ending_equity_amount = EXCLUDED.ending_equity_amount, + trades = EXCLUDED.trades, + positions = EXCLUDED.positions +` + +type UpsertResultParams struct { + RunID string + StartingCashCurrency string + StartingCashAmount pgtype.Numeric + EndingEquityCurrency string + EndingEquityAmount pgtype.Numeric + Trades []byte + Positions []byte +} + +func (q *Queries) UpsertResult(ctx context.Context, arg UpsertResultParams) error { + _, err := q.db.Exec(ctx, upsertResult, + arg.RunID, + arg.StartingCashCurrency, + arg.StartingCashAmount, + arg.EndingEquityCurrency, + arg.EndingEquityAmount, + arg.Trades, + arg.Positions, + ) + return err +} + const upsertRun = `-- name: UpsertRun :exec INSERT INTO backtest_runs (id, strategy_id, market, timeframe, from_time, to_time, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) diff --git a/services/worker/internal/storage/postgres/store.go b/services/worker/internal/storage/postgres/store.go index f864b72..c207437 100644 --- a/services/worker/internal/storage/postgres/store.go +++ b/services/worker/internal/storage/postgres/store.go @@ -2,9 +2,11 @@ package postgres import ( "context" + "errors" "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -17,6 +19,7 @@ import ( var _ storage.InstrumentStore = (*Store)(nil) var _ storage.BarStore = (*Store)(nil) var _ storage.BacktestRunStore = (*Store)(nil) +var _ storage.BacktestResultStore = (*Store)(nil) type Store struct { pool *pgxpool.Pool @@ -123,6 +126,9 @@ func (s *Store) UpsertRun(ctx context.Context, run backtest.Run) error { func (s *Store) GetRun(ctx context.Context, id backtest.RunID) (backtest.Run, error) { row, err := s.queries.GetRun(ctx, string(id)) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return backtest.Run{}, storage.ErrRunNotFound + } return backtest.Run{}, fmt.Errorf("failed to get run: %w", err) } run, err := mapRowToRun(row) @@ -131,3 +137,29 @@ func (s *Store) GetRun(ctx context.Context, id backtest.RunID) (backtest.Run, er } return run, nil } + +func (s *Store) UpsertResult(ctx context.Context, result backtest.Result) error { + params, err := mapResultToParams(result) + if err != nil { + return fmt.Errorf("failed to map result: %w", err) + } + if err := s.queries.UpsertResult(ctx, params); err != nil { + return fmt.Errorf("failed to upsert result: %w", err) + } + return nil +} + +func (s *Store) GetResult(ctx context.Context, id backtest.RunID) (backtest.Result, error) { + row, err := s.queries.GetResult(ctx, string(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return backtest.Result{}, storage.ErrResultNotFound + } + return backtest.Result{}, fmt.Errorf("failed to get result: %w", err) + } + res, err := mapRowToResult(row) + if err != nil { + return backtest.Result{}, fmt.Errorf("failed to map result row: %w", err) + } + return res, nil +}