feat: backtest engine baseline implementation
- Add backtest proto definitions and generated code - Update domain types for backtest results and fixtures - Add PostgreSQL migrations for backtest tables - Implement storage layer for backtest result persistence - Add backtest job definitions and execution pipeline - Remove obsolete agent-task documents for completed items
This commit is contained in:
parent
b2f98f3e19
commit
38b68315fc
26 changed files with 1932 additions and 872 deletions
|
|
@ -1,115 +0,0 @@
|
|||
<!-- task=m-backtest-engine-baseline/01_worker_lifecycle plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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.
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
<!-- task=m-backtest-engine-baseline/01_worker_lifecycle plan=0 tag=API -->
|
||||
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
<!-- task=m-backtest-engine-baseline/02+01_result_store plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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.
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
<!-- task=m-backtest-engine-baseline/02+01_result_store plan=0 tag=API -->
|
||||
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
<!-- task=m-backtest-engine-baseline/03+01,02_fixture_verification plan=0 tag=TEST -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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.
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
<!-- task=m-backtest-engine-baseline/03+01,02_fixture_verification plan=0 tag=TEST -->
|
||||
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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<BacktestTrade>(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<BacktestPosition>(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<BacktestTrade>? trades,
|
||||
$core.Iterable<BacktestPosition>? 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<BacktestTrade>(4, _omitFieldNames ? '' : 'trades',
|
||||
subBuilder: BacktestTrade.create)
|
||||
..pPM<BacktestPosition>(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<BacktestTrade> get trades => $_getList(3);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$pb.PbList<BacktestPosition> get positions => $_getList(4);
|
||||
}
|
||||
|
||||
class GetBacktestResultRequest extends $pb.GeneratedMessage {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
156
services/worker/internal/backtest/engine.go
Normal file
156
services/worker/internal/backtest/engine.go
Normal file
|
|
@ -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
|
||||
}
|
||||
126
services/worker/internal/backtest/engine_test.go
Normal file
126
services/worker/internal/backtest/engine_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
260
services/worker/internal/backtest/fixture_test.go
Normal file
260
services/worker/internal/backtest/fixture_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
160
services/worker/internal/jobs/backtest_jobs.go
Normal file
160
services/worker/internal/jobs/backtest_jobs.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
321
services/worker/internal/jobs/backtest_jobs_test.go
Normal file
321
services/worker/internal/jobs/backtest_jobs_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue