diff --git a/agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md b/agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md deleted file mode 100644 index 7686523..0000000 --- a/agent-roadmap/phase/backtest-loop/milestones/backtest-engine-baseline.md +++ /dev/null @@ -1,65 +0,0 @@ -# Milestone: Backtest Engine Baseline - -## 위치 - -- Roadmap: `agent-roadmap/ROADMAP.md` -- Phase: `agent-roadmap/phase/backtest-loop/PHASE.md` - -## 목표 - -정규화된 일봉 데이터를 사용해 전략 실행, portfolio state, result summary가 재현 가능한 backtest loop를 만든다. 이 milestone은 백테스트 기반을 ALT 확장의 중심축으로 고정한다. - -## 상태 - -[진행중] - -## 구현 잠금 - -- 상태: 해제 -- 결정 필요: 없음 - -## 범위 - -- daily bar 기반 strategy execution skeleton -- deterministic backtest run lifecycle -- starting cash, ending equity, trades/positions summary -- result persistence and query -- fixture data 기반 검증 - -## 기능 - -### Epic: [backtest-engine] Deterministic backtest engine - -일봉 기반 전략 실행, portfolio state, result 저장과 fixture 검증 흐름을 묶는다. - -- [ ] [run-lifecycle] backtest run의 pending/running/succeeded/failed/canceled lifecycle이 있다. 검증: worker가 backtest 실행을 소유하고 API는 실행 요청/조회 경계만 담당한다. -- [x] [strategy-port] strategy를 engine에 주입할 port 또는 interface가 있다. -- [x] [portfolio-state] cash, position, equity를 계산하는 최소 portfolio state가 있다. -- [ ] [result-store] backtest result가 저장되고 조회된다. 검증: 결과가 contracts/client에서 조회 가능한 형태로 연결될 수 있다. -- [ ] [fixture-test] fixture daily bars로 deterministic test가 통과한다. 검증: 동일한 fixture와 strategy 입력으로 동일한 result가 나오고 `bin/test`와 `bin/lint`가 통과한다. - -## 완료 리뷰 - -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 아직 기능 Task와 각 Task의 검증 조건이 충족되지 않았다. -- 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 - -## 범위 제외 - -- 실시간 전략 실행 -- paper/live order routing -- 고급 리스크 모델 -- 고급 성과 분석 지표 전체 - -## 작업 컨텍스트 - -- 관련 경로: `packages/domain/backtest/`, `services/worker/`, `packages/contracts/proto/alt/v1/backtest.proto` -- 표준선(선택): Go는 Hexagonal Architecture, light CQRS, domain event 표현을 기본 설계 프레임으로 삼는다. -- 선행 작업: Korea Daily Data Foundation -- 후속 작업: Backtest Analysis Surface -- 진행 근거(2026-05-30): `[strategy-port]`, `[portfolio-state]`는 `packages/domain/backtest/types.go`, `packages/domain/backtest/types_test.go`, `go test ./packages/domain/...`로 확인했다. -- 확인 필요: 없음 diff --git a/agent-task/m-backtest-analysis-surface/03+01,02_api_client_boundary/CODE_REVIEW-cloud-G06.md b/agent-task/m-backtest-analysis-surface/03+01,02_api_client_boundary/CODE_REVIEW-cloud-G06.md new file mode 100644 index 0000000..1beafda --- /dev/null +++ b/agent-task/m-backtest-analysis-surface/03+01,02_api_client_boundary/CODE_REVIEW-cloud-G06.md @@ -0,0 +1,133 @@ + + +# Code Review Reference - BAS-BOUNDARY + +> **[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-analysis-surface/03+01,02_api_client_boundary, plan=0, tag=BAS-BOUNDARY + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +1. 판정을 append한다. +2. active plan/review files를 `.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/m-backtest-analysis-surface/03+01,02_api_client_boundary/`로 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [BAS-BOUNDARY-1] Align API parser boundary | [ ] | +| [BAS-BOUNDARY-2] Add typed client request helpers | [ ] | +| [BAS-BOUNDARY-3] Test backtest socket helpers | [ ] | +| [BAS-BOUNDARY-4] Boundary verification | [ ] | + +## 구현 체크리스트 + +- [ ] [BAS-BOUNDARY-1] API parser map and tests include all analysis request/response messages if not already done by contracts task. +- [ ] [BAS-BOUNDARY-2] `AltSocketClient` exposes typed backtest list/detail/result/compare request helpers. +- [ ] [BAS-BOUNDARY-3] Flutter socket client tests verify request type names, payloads, and response parsing. 검증: worker/API/client 경계가 backtest result를 일관되게 다룬다. +- [ ] [BAS-BOUNDARY-4] `go test ./services/api/...`, `cd apps/client && flutter test test/integrations/socket/alt_socket_client_test.dart`, `bin/test`, `bin/lint`를 실행한다. 검증: `bin/test`와 `bin/lint`가 통과한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Client helper request/response generic types match generated protobuf messages. +- Fake websocket tests assert `PacketBase.typeName`, request payload, and parsed response. +- API parser map remains aligned with client parser map for analysis messages. +- No worker internals are imported into API or client. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### BAS-BOUNDARY-1 중간 검증 +```text +$ go test ./services/api/... +(output) +``` + +### BAS-BOUNDARY-2 중간 검증 +```text +$ cd apps/client && flutter analyze --no-fatal-infos +(output) +``` + +### BAS-BOUNDARY-3 중간 검증 +```text +$ cd apps/client && flutter test test/integrations/socket/alt_socket_client_test.dart +(output) +``` + +### 최종 검증 +```text +$ go test ./services/api/... +$ cd apps/client && flutter test test/integrations/socket/alt_socket_client_test.dart +$ bin/test +$ bin/lint +$ git diff --check +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-backtest-analysis-surface/03+01,02_api_client_boundary/PLAN-cloud-G06.md b/agent-task/m-backtest-analysis-surface/03+01,02_api_client_boundary/PLAN-cloud-G06.md new file mode 100644 index 0000000..4cf6ea6 --- /dev/null +++ b/agent-task/m-backtest-analysis-surface/03+01,02_api_client_boundary/PLAN-cloud-G06.md @@ -0,0 +1,229 @@ + + +# Plan - BAS-BOUNDARY + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수다. 구현 후 검증을 실행하고, active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 사용자 결정, 외부 환경 준비, 범위 충돌이 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남기고 멈춘다. archive, `complete.log`, 최종 판정은 code-review 전용이다. + +## 배경 + +Contract와 worker storage가 준비되더라도 operator client가 typed backtest query 요청을 보낼 수 없으면 analysis surface가 실제 사용 흐름으로 연결되지 않는다. 이 계획은 API parser boundary와 Flutter socket client request helpers를 좁게 연결해 run list/detail/compare 표면의 첫 호출 경계를 만든다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 조건은 active `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 이를 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/backtest-loop/milestones/backtest-analysis-surface.md` +- `services/api/internal/contracts/parser_map.go` +- `services/api/internal/contracts/parser_map_test.go` +- `apps/client/lib/src/contracts/alt_contracts.dart` +- `apps/client/test/contracts/alt_contracts_test.dart` +- `apps/client/lib/src/integrations/socket/alt_socket_client.dart` +- `apps/client/test/integrations/socket/alt_socket_client_test.dart` +- `packages/contracts/proto/alt/v1/backtest.proto` +- `bin/test` +- `bin/lint` +- `services/api/go.mod` +- `apps/client/pubspec.yaml` + +### 테스트 커버리지 공백 + +- `AltSocketClient` currently tests only `hello()` request/response at `apps/client/test/integrations/socket/alt_socket_client_test.dart:113`. +- No client test sends backtest run list/detail/result/compare requests through proto-socket. +- API parser tests cover current message types only and must remain aligned with generated messages from `01_contract_surface`. + +### 심볼 참조 + +- Renamed/removed symbols: none planned. +- New client methods will call existing inherited `sendRequest` in `AltSocketClient`, currently used by `hello()` at `apps/client/lib/src/integrations/socket/alt_socket_client.dart:32`. + +### 분할 판단 + +- Split policy evaluated before writing plan files. +- This task depends on `01_contract_surface` and `02+01_worker_analysis_store`. +- It is separate because API/client boundary tests can be reviewed independently after schema and storage behavior exist. +- It does not create full Flutter operator UI; that belongs to later `Flutter Operator Console`. + +### 범위 결정 근거 + +- Include API parser alignment if new messages were not already covered by `01_contract_surface`, typed client request helpers, and socket client tests. +- Exclude server-side business handlers because current API service only owns parser/socket boundary; worker owns execution and storage. +- Exclude dashboard UI and navigation. +- Exclude migrations/storage behavior already covered by `02+01_worker_analysis_store`. + +### 빌드 등급 + +- build lane: `cloud-G06`; review lane: `cloud-G06`. +- Rationale: cross-language boundary helpers and tests, but no storage or schema design should remain by this stage. + +## 구현 체크리스트 + +- [ ] [BAS-BOUNDARY-1] API parser map and tests include all analysis request/response messages if not already done by contracts task. +- [ ] [BAS-BOUNDARY-2] `AltSocketClient` exposes typed backtest list/detail/result/compare request helpers. +- [ ] [BAS-BOUNDARY-3] Flutter socket client tests verify request type names, payloads, and response parsing. 검증: worker/API/client 경계가 backtest result를 일관되게 다룬다. +- [ ] [BAS-BOUNDARY-4] `go test ./services/api/...`, `cd apps/client && flutter test test/integrations/socket/alt_socket_client_test.dart`, `bin/test`, `bin/lint`를 실행한다. 검증: `bin/test`와 `bin/lint`가 통과한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [BAS-BOUNDARY-1] Align API parser boundary + +문제: `services/api/internal/contracts/parser_map.go:19` currently registers existing backtest messages. If `01_contract_surface` added list/detail/compare messages and did not register them, API parser tests will not cover the analysis request surface. + +해결 방법: + +Before: + +```go +19 protoSocket.TypeNameOf(&altv1.StartBacktestRequest{}): parserFor(func() proto.Message { return &altv1.StartBacktestRequest{} }), +21 protoSocket.TypeNameOf(&altv1.GetBacktestRunRequest{}): parserFor(func() proto.Message { return &altv1.GetBacktestRunRequest{} }), +23 protoSocket.TypeNameOf(&altv1.GetBacktestResultRequest{}): parserFor(func() proto.Message { return &altv1.GetBacktestResultRequest{} }), +``` + +After: ensure `ListBacktestRuns*`, `GetBacktestRunDetail*`, and `CompareBacktestRuns*` generated messages are present in `ParserMap()` and `TestParserMapIncludesAltMessages`. + +수정 파일 및 체크리스트: + +- [ ] `services/api/internal/contracts/parser_map.go` includes all analysis request/response messages. +- [ ] `services/api/internal/contracts/parser_map_test.go` includes all analysis messages and round-trip parsing. + +테스트 작성: update existing parser map test only. + +중간 검증: + +```bash +go test ./services/api/... +``` + +Expected: exits 0. + +### [BAS-BOUNDARY-2] Add typed client request helpers + +문제: `AltSocketClient.hello()` at `apps/client/lib/src/integrations/socket/alt_socket_client.dart:32` is the only typed helper. Backtest analysis requests would require callers to use raw generated messages directly. + +해결 방법: + +Before: + +```dart +32 Future hello({Duration timeout = const Duration(seconds: 2)}) { +33 return sendRequest( +``` + +After: + +```dart +Future listBacktestRuns( + ListBacktestRunsRequest request, { + Duration timeout = const Duration(seconds: 2), +}) { + return sendRequest( + request, + timeout: timeout, + ); +} +``` + +Add equivalent helpers for `GetBacktestRunDetailRequest`, existing `GetBacktestResultRequest`, and `CompareBacktestRunsRequest`. + +수정 파일 및 체크리스트: + +- [ ] `apps/client/lib/src/integrations/socket/alt_socket_client.dart` imports generated `backtest.pb.dart`. +- [ ] Add `listBacktestRuns`, `getBacktestRunDetail`, `getBacktestResult`, and `compareBacktestRuns` helpers. +- [ ] Keep default timeout consistent with `hello()`. + +테스트 작성: tests in BAS-BOUNDARY-3. + +중간 검증: + +```bash +cd apps/client && flutter analyze --no-fatal-infos +``` + +Expected: exits 0. + +### [BAS-BOUNDARY-3] Test backtest socket helpers + +문제: `apps/client/test/integrations/socket/alt_socket_client_test.dart:113` verifies hello only; no test asserts backtest request type names or response parsing over `PacketBase`. + +해결 방법: Extend fake websocket tests with one helper to assert sent packet type/payload and feed a matching response packet. Cover at least list runs, run detail, get result, and compare runs. + +수정 파일 및 체크리스트: + +- [ ] `apps/client/test/integrations/socket/alt_socket_client_test.dart` imports generated `backtest.pb.dart`. +- [ ] Add tests for each new client helper. +- [ ] Tests assert sent `PacketBase.typeName`, serialized request fields, and parsed response fields. + +테스트 작성: add focused tests in existing `AltSocketClient tests` group. + +중간 검증: + +```bash +cd apps/client && flutter test test/integrations/socket/alt_socket_client_test.dart +``` + +Expected: exits 0. + +### [BAS-BOUNDARY-4] Boundary verification + +문제: API parser, generated contract, and client helper changes must remain consistent across the workspace. + +해결 방법: run focused API/client checks and full workspace smoke. + +수정 파일 및 체크리스트: + +- [ ] Run API parser tests. +- [ ] Run client socket helper tests. +- [ ] Run full `bin/test`. +- [ ] Run full `bin/lint`. +- [ ] Run `git diff --check`. + +테스트 작성: no extra tests beyond BAS-BOUNDARY-1 and BAS-BOUNDARY-3. + +중간 검증: + +```bash +go test ./services/api/... +cd apps/client && flutter test test/integrations/socket/alt_socket_client_test.dart +bin/test +bin/lint +git diff --check +``` + +Expected: all commands exit 0. Go test cache output is acceptable after focused API tests pass; Flutter test output must be actual command output. + +## 의존 관계 및 구현 순서 + +Directory dependency `03+01,02_api_client_boundary` means both predecessor task directories must produce `complete.log` before this task starts: + +- `agent-task/m-backtest-analysis-surface/01_contract_surface/complete.log` +- `agent-task/m-backtest-analysis-surface/02+01_worker_analysis_store/complete.log` + +Do not begin if either file is absent. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/api/internal/contracts/parser_map.go` | BAS-BOUNDARY-1 | +| `services/api/internal/contracts/parser_map_test.go` | BAS-BOUNDARY-1 | +| `apps/client/lib/src/integrations/socket/alt_socket_client.dart` | BAS-BOUNDARY-2 | +| `apps/client/test/integrations/socket/alt_socket_client_test.dart` | BAS-BOUNDARY-3 | + +## 최종 검증 + +```bash +go test ./services/api/... +cd apps/client && flutter test test/integrations/socket/alt_socket_client_test.dart +bin/test +bin/lint +git diff --check +``` + +Expected: all commands exit 0. Go test cache output is acceptable after focused API tests pass; Flutter test output must be actual command output. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/client/lib/src/contracts/alt_contracts.dart b/apps/client/lib/src/contracts/alt_contracts.dart index 297bb97..236c72e 100644 --- a/apps/client/lib/src/contracts/alt_contracts.dart +++ b/apps/client/lib/src/contracts/alt_contracts.dart @@ -19,5 +19,11 @@ Map)> altParserMap() { GetBacktestResultRequest.getDefault().info_.qualifiedMessageName: GetBacktestResultRequest.fromBuffer, GetBacktestResultResponse.getDefault().info_.qualifiedMessageName: GetBacktestResultResponse.fromBuffer, BacktestResult.getDefault().info_.qualifiedMessageName: BacktestResult.fromBuffer, + ListBacktestRunsRequest.getDefault().info_.qualifiedMessageName: ListBacktestRunsRequest.fromBuffer, + ListBacktestRunsResponse.getDefault().info_.qualifiedMessageName: ListBacktestRunsResponse.fromBuffer, + GetBacktestRunDetailRequest.getDefault().info_.qualifiedMessageName: GetBacktestRunDetailRequest.fromBuffer, + GetBacktestRunDetailResponse.getDefault().info_.qualifiedMessageName: GetBacktestRunDetailResponse.fromBuffer, + CompareBacktestRunsRequest.getDefault().info_.qualifiedMessageName: CompareBacktestRunsRequest.fromBuffer, + CompareBacktestRunsResponse.getDefault().info_.qualifiedMessageName: CompareBacktestRunsResponse.fromBuffer, }; } diff --git a/apps/client/lib/src/generated/alt/v1/backtest.pb.dart b/apps/client/lib/src/generated/alt/v1/backtest.pb.dart index f198893..fcaa0f8 100644 --- a/apps/client/lib/src/generated/alt/v1/backtest.pb.dart +++ b/apps/client/lib/src/generated/alt/v1/backtest.pb.dart @@ -16,7 +16,7 @@ import 'package:fixnum/fixnum.dart' as $fixnum; import 'package:protobuf/protobuf.dart' as $pb; import 'backtest.pbenum.dart'; -import 'common.pbenum.dart' as $1; +import 'common.pb.dart' as $1; import 'market.pb.dart' as $0; export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; @@ -653,6 +653,175 @@ class BacktestPosition extends $pb.GeneratedMessage { $0.Price ensureLastPrice() => $_ensure(2); } +class BacktestSummaryMetrics extends $pb.GeneratedMessage { + factory BacktestSummaryMetrics({ + $0.Price? startingCash, + $0.Price? endingEquity, + $1.Decimal? totalReturn, + $core.int? tradeCount, + }) { + final result = create(); + if (startingCash != null) result.startingCash = startingCash; + if (endingEquity != null) result.endingEquity = endingEquity; + if (totalReturn != null) result.totalReturn = totalReturn; + if (tradeCount != null) result.tradeCount = tradeCount; + return result; + } + + BacktestSummaryMetrics._(); + + factory BacktestSummaryMetrics.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory BacktestSummaryMetrics.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'BacktestSummaryMetrics', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aOM<$0.Price>(1, _omitFieldNames ? '' : 'startingCash', + subBuilder: $0.Price.create) + ..aOM<$0.Price>(2, _omitFieldNames ? '' : 'endingEquity', + subBuilder: $0.Price.create) + ..aOM<$1.Decimal>(3, _omitFieldNames ? '' : 'totalReturn', + subBuilder: $1.Decimal.create) + ..aI(4, _omitFieldNames ? '' : 'tradeCount') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestSummaryMetrics clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestSummaryMetrics copyWith( + void Function(BacktestSummaryMetrics) updates) => + super.copyWith((message) => updates(message as BacktestSummaryMetrics)) + as BacktestSummaryMetrics; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BacktestSummaryMetrics create() => BacktestSummaryMetrics._(); + @$core.override + BacktestSummaryMetrics createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static BacktestSummaryMetrics getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static BacktestSummaryMetrics? _defaultInstance; + + @$pb.TagNumber(1) + $0.Price get startingCash => $_getN(0); + @$pb.TagNumber(1) + set startingCash($0.Price value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasStartingCash() => $_has(0); + @$pb.TagNumber(1) + void clearStartingCash() => $_clearField(1); + @$pb.TagNumber(1) + $0.Price ensureStartingCash() => $_ensure(0); + + @$pb.TagNumber(2) + $0.Price get endingEquity => $_getN(1); + @$pb.TagNumber(2) + set endingEquity($0.Price value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasEndingEquity() => $_has(1); + @$pb.TagNumber(2) + void clearEndingEquity() => $_clearField(2); + @$pb.TagNumber(2) + $0.Price ensureEndingEquity() => $_ensure(1); + + @$pb.TagNumber(3) + $1.Decimal get totalReturn => $_getN(2); + @$pb.TagNumber(3) + set totalReturn($1.Decimal value) => $_setField(3, value); + @$pb.TagNumber(3) + $core.bool hasTotalReturn() => $_has(2); + @$pb.TagNumber(3) + void clearTotalReturn() => $_clearField(3); + @$pb.TagNumber(3) + $1.Decimal ensureTotalReturn() => $_ensure(2); + + @$pb.TagNumber(4) + $core.int get tradeCount => $_getIZ(3); + @$pb.TagNumber(4) + set tradeCount($core.int value) => $_setSignedInt32(3, value); + @$pb.TagNumber(4) + $core.bool hasTradeCount() => $_has(3); + @$pb.TagNumber(4) + void clearTradeCount() => $_clearField(4); +} + +class BacktestEquityPoint extends $pb.GeneratedMessage { + factory BacktestEquityPoint({ + $fixnum.Int64? timestampUnixMs, + $0.Price? equity, + }) { + final result = create(); + if (timestampUnixMs != null) result.timestampUnixMs = timestampUnixMs; + if (equity != null) result.equity = equity; + return result; + } + + BacktestEquityPoint._(); + + factory BacktestEquityPoint.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory BacktestEquityPoint.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'BacktestEquityPoint', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aInt64(1, _omitFieldNames ? '' : 'timestampUnixMs') + ..aOM<$0.Price>(2, _omitFieldNames ? '' : 'equity', + subBuilder: $0.Price.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestEquityPoint clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BacktestEquityPoint copyWith(void Function(BacktestEquityPoint) updates) => + super.copyWith((message) => updates(message as BacktestEquityPoint)) + as BacktestEquityPoint; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BacktestEquityPoint create() => BacktestEquityPoint._(); + @$core.override + BacktestEquityPoint createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static BacktestEquityPoint getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static BacktestEquityPoint? _defaultInstance; + + @$pb.TagNumber(1) + $fixnum.Int64 get timestampUnixMs => $_getI64(0); + @$pb.TagNumber(1) + set timestampUnixMs($fixnum.Int64 value) => $_setInt64(0, value); + @$pb.TagNumber(1) + $core.bool hasTimestampUnixMs() => $_has(0); + @$pb.TagNumber(1) + void clearTimestampUnixMs() => $_clearField(1); + + @$pb.TagNumber(2) + $0.Price get equity => $_getN(1); + @$pb.TagNumber(2) + set equity($0.Price value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasEquity() => $_has(1); + @$pb.TagNumber(2) + void clearEquity() => $_clearField(2); + @$pb.TagNumber(2) + $0.Price ensureEquity() => $_ensure(1); +} + class BacktestResult extends $pb.GeneratedMessage { factory BacktestResult({ $core.String? runId, @@ -660,6 +829,8 @@ class BacktestResult extends $pb.GeneratedMessage { $0.Price? endingEquity, $core.Iterable? trades, $core.Iterable? positions, + BacktestSummaryMetrics? summary, + $core.Iterable? equityCurve, }) { final result = create(); if (runId != null) result.runId = runId; @@ -667,6 +838,8 @@ class BacktestResult extends $pb.GeneratedMessage { if (endingEquity != null) result.endingEquity = endingEquity; if (trades != null) result.trades.addAll(trades); if (positions != null) result.positions.addAll(positions); + if (summary != null) result.summary = summary; + if (equityCurve != null) result.equityCurve.addAll(equityCurve); return result; } @@ -692,6 +865,10 @@ class BacktestResult extends $pb.GeneratedMessage { subBuilder: BacktestTrade.create) ..pPM(5, _omitFieldNames ? '' : 'positions', subBuilder: BacktestPosition.create) + ..aOM(6, _omitFieldNames ? '' : 'summary', + subBuilder: BacktestSummaryMetrics.create) + ..pPM(7, _omitFieldNames ? '' : 'equityCurve', + subBuilder: BacktestEquityPoint.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -749,6 +926,20 @@ class BacktestResult extends $pb.GeneratedMessage { @$pb.TagNumber(5) $pb.PbList get positions => $_getList(4); + + @$pb.TagNumber(6) + BacktestSummaryMetrics get summary => $_getN(5); + @$pb.TagNumber(6) + set summary(BacktestSummaryMetrics value) => $_setField(6, value); + @$pb.TagNumber(6) + $core.bool hasSummary() => $_has(5); + @$pb.TagNumber(6) + void clearSummary() => $_clearField(6); + @$pb.TagNumber(6) + BacktestSummaryMetrics ensureSummary() => $_ensure(5); + + @$pb.TagNumber(7) + $pb.PbList get equityCurve => $_getList(6); } class GetBacktestResultRequest extends $pb.GeneratedMessage { @@ -864,6 +1055,346 @@ class GetBacktestResultResponse extends $pb.GeneratedMessage { BacktestResult ensureResult() => $_ensure(0); } +class ListBacktestRunsRequest extends $pb.GeneratedMessage { + factory ListBacktestRunsRequest({ + BacktestRunStatus? status, + }) { + final result = create(); + if (status != null) result.status = status; + return result; + } + + ListBacktestRunsRequest._(); + + factory ListBacktestRunsRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ListBacktestRunsRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ListBacktestRunsRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aE(1, _omitFieldNames ? '' : 'status', + enumValues: BacktestRunStatus.values) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ListBacktestRunsRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ListBacktestRunsRequest copyWith( + void Function(ListBacktestRunsRequest) updates) => + super.copyWith((message) => updates(message as ListBacktestRunsRequest)) + as ListBacktestRunsRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ListBacktestRunsRequest create() => ListBacktestRunsRequest._(); + @$core.override + ListBacktestRunsRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static ListBacktestRunsRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ListBacktestRunsRequest? _defaultInstance; + + @$pb.TagNumber(1) + BacktestRunStatus get status => $_getN(0); + @$pb.TagNumber(1) + set status(BacktestRunStatus value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasStatus() => $_has(0); + @$pb.TagNumber(1) + void clearStatus() => $_clearField(1); +} + +class ListBacktestRunsResponse extends $pb.GeneratedMessage { + factory ListBacktestRunsResponse({ + $core.Iterable? runs, + }) { + final result = create(); + if (runs != null) result.runs.addAll(runs); + return result; + } + + ListBacktestRunsResponse._(); + + factory ListBacktestRunsResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ListBacktestRunsResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ListBacktestRunsResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..pPM(1, _omitFieldNames ? '' : 'runs', + subBuilder: BacktestRun.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ListBacktestRunsResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ListBacktestRunsResponse copyWith( + void Function(ListBacktestRunsResponse) updates) => + super.copyWith((message) => updates(message as ListBacktestRunsResponse)) + as ListBacktestRunsResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ListBacktestRunsResponse create() => ListBacktestRunsResponse._(); + @$core.override + ListBacktestRunsResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static ListBacktestRunsResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ListBacktestRunsResponse? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get runs => $_getList(0); +} + +class GetBacktestRunDetailRequest extends $pb.GeneratedMessage { + factory GetBacktestRunDetailRequest({ + $core.String? runId, + }) { + final result = create(); + if (runId != null) result.runId = runId; + return result; + } + + GetBacktestRunDetailRequest._(); + + factory GetBacktestRunDetailRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory GetBacktestRunDetailRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'GetBacktestRunDetailRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'runId') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetBacktestRunDetailRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetBacktestRunDetailRequest copyWith( + void Function(GetBacktestRunDetailRequest) updates) => + super.copyWith( + (message) => updates(message as GetBacktestRunDetailRequest)) + as GetBacktestRunDetailRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GetBacktestRunDetailRequest create() => + GetBacktestRunDetailRequest._(); + @$core.override + GetBacktestRunDetailRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static GetBacktestRunDetailRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static GetBacktestRunDetailRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get runId => $_getSZ(0); + @$pb.TagNumber(1) + set runId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasRunId() => $_has(0); + @$pb.TagNumber(1) + void clearRunId() => $_clearField(1); +} + +class GetBacktestRunDetailResponse extends $pb.GeneratedMessage { + factory GetBacktestRunDetailResponse({ + BacktestRun? run, + BacktestResult? result, + }) { + final result$ = create(); + if (run != null) result$.run = run; + if (result != null) result$.result = result; + return result$; + } + + GetBacktestRunDetailResponse._(); + + factory GetBacktestRunDetailResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory GetBacktestRunDetailResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'GetBacktestRunDetailResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..aOM(1, _omitFieldNames ? '' : 'run', + subBuilder: BacktestRun.create) + ..aOM(2, _omitFieldNames ? '' : 'result', + subBuilder: BacktestResult.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetBacktestRunDetailResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + GetBacktestRunDetailResponse copyWith( + void Function(GetBacktestRunDetailResponse) updates) => + super.copyWith( + (message) => updates(message as GetBacktestRunDetailResponse)) + as GetBacktestRunDetailResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GetBacktestRunDetailResponse create() => + GetBacktestRunDetailResponse._(); + @$core.override + GetBacktestRunDetailResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static GetBacktestRunDetailResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static GetBacktestRunDetailResponse? _defaultInstance; + + @$pb.TagNumber(1) + BacktestRun get run => $_getN(0); + @$pb.TagNumber(1) + set run(BacktestRun value) => $_setField(1, value); + @$pb.TagNumber(1) + $core.bool hasRun() => $_has(0); + @$pb.TagNumber(1) + void clearRun() => $_clearField(1); + @$pb.TagNumber(1) + BacktestRun ensureRun() => $_ensure(0); + + @$pb.TagNumber(2) + BacktestResult get result => $_getN(1); + @$pb.TagNumber(2) + set result(BacktestResult value) => $_setField(2, value); + @$pb.TagNumber(2) + $core.bool hasResult() => $_has(1); + @$pb.TagNumber(2) + void clearResult() => $_clearField(2); + @$pb.TagNumber(2) + BacktestResult ensureResult() => $_ensure(1); +} + +class CompareBacktestRunsRequest extends $pb.GeneratedMessage { + factory CompareBacktestRunsRequest({ + $core.Iterable<$core.String>? runIds, + }) { + final result = create(); + if (runIds != null) result.runIds.addAll(runIds); + return result; + } + + CompareBacktestRunsRequest._(); + + factory CompareBacktestRunsRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory CompareBacktestRunsRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'CompareBacktestRunsRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..pPS(1, _omitFieldNames ? '' : 'runIds') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CompareBacktestRunsRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CompareBacktestRunsRequest copyWith( + void Function(CompareBacktestRunsRequest) updates) => + super.copyWith( + (message) => updates(message as CompareBacktestRunsRequest)) + as CompareBacktestRunsRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static CompareBacktestRunsRequest create() => CompareBacktestRunsRequest._(); + @$core.override + CompareBacktestRunsRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static CompareBacktestRunsRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static CompareBacktestRunsRequest? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList<$core.String> get runIds => $_getList(0); +} + +class CompareBacktestRunsResponse extends $pb.GeneratedMessage { + factory CompareBacktestRunsResponse({ + $core.Iterable? results, + }) { + final result = create(); + if (results != null) result.results.addAll(results); + return result; + } + + CompareBacktestRunsResponse._(); + + factory CompareBacktestRunsResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory CompareBacktestRunsResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'CompareBacktestRunsResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'), + createEmptyInstance: create) + ..pPM(1, _omitFieldNames ? '' : 'results', + subBuilder: BacktestResult.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CompareBacktestRunsResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + CompareBacktestRunsResponse copyWith( + void Function(CompareBacktestRunsResponse) updates) => + super.copyWith( + (message) => updates(message as CompareBacktestRunsResponse)) + as CompareBacktestRunsResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static CompareBacktestRunsResponse create() => + CompareBacktestRunsResponse._(); + @$core.override + CompareBacktestRunsResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static CompareBacktestRunsResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static CompareBacktestRunsResponse? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get results => $_getList(0); +} + const $core.bool _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); const $core.bool _omitMessageNames = diff --git a/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart b/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart index a10fd55..5ff8437 100644 --- a/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart +++ b/apps/client/lib/src/generated/alt/v1/backtest.pbjson.dart @@ -251,6 +251,66 @@ final $typed_data.Uint8List backtestPositionDescriptor = $convert.base64Decode( 'IsCghxdWFudGl0eRgCIAEoCzIQLmFsdC52MS5RdWFudGl0eVIIcXVhbnRpdHkSLAoKbGFzdF9w' 'cmljZRgDIAEoCzINLmFsdC52MS5QcmljZVIJbGFzdFByaWNl'); +@$core.Deprecated('Use backtestSummaryMetricsDescriptor instead') +const BacktestSummaryMetrics$json = { + '1': 'BacktestSummaryMetrics', + '2': [ + { + '1': 'starting_cash', + '3': 1, + '4': 1, + '5': 11, + '6': '.alt.v1.Price', + '10': 'startingCash' + }, + { + '1': 'ending_equity', + '3': 2, + '4': 1, + '5': 11, + '6': '.alt.v1.Price', + '10': 'endingEquity' + }, + { + '1': 'total_return', + '3': 3, + '4': 1, + '5': 11, + '6': '.alt.v1.Decimal', + '10': 'totalReturn' + }, + {'1': 'trade_count', '3': 4, '4': 1, '5': 5, '10': 'tradeCount'}, + ], +}; + +/// Descriptor for `BacktestSummaryMetrics`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List backtestSummaryMetricsDescriptor = $convert.base64Decode( + 'ChZCYWNrdGVzdFN1bW1hcnlNZXRyaWNzEjIKDXN0YXJ0aW5nX2Nhc2gYASABKAsyDS5hbHQudj' + 'EuUHJpY2VSDHN0YXJ0aW5nQ2FzaBIyCg1lbmRpbmdfZXF1aXR5GAIgASgLMg0uYWx0LnYxLlBy' + 'aWNlUgxlbmRpbmdFcXVpdHkSMgoMdG90YWxfcmV0dXJuGAMgASgLMg8uYWx0LnYxLkRlY2ltYW' + 'xSC3RvdGFsUmV0dXJuEh8KC3RyYWRlX2NvdW50GAQgASgFUgp0cmFkZUNvdW50'); + +@$core.Deprecated('Use backtestEquityPointDescriptor instead') +const BacktestEquityPoint$json = { + '1': 'BacktestEquityPoint', + '2': [ + {'1': 'timestamp_unix_ms', '3': 1, '4': 1, '5': 3, '10': 'timestampUnixMs'}, + { + '1': 'equity', + '3': 2, + '4': 1, + '5': 11, + '6': '.alt.v1.Price', + '10': 'equity' + }, + ], +}; + +/// Descriptor for `BacktestEquityPoint`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List backtestEquityPointDescriptor = $convert.base64Decode( + 'ChNCYWNrdGVzdEVxdWl0eVBvaW50EioKEXRpbWVzdGFtcF91bml4X21zGAEgASgDUg90aW1lc3' + 'RhbXBVbml4TXMSJQoGZXF1aXR5GAIgASgLMg0uYWx0LnYxLlByaWNlUgZlcXVpdHk='); + @$core.Deprecated('Use backtestResultDescriptor instead') const BacktestResult$json = { '1': 'BacktestResult', @@ -288,6 +348,22 @@ const BacktestResult$json = { '6': '.alt.v1.BacktestPosition', '10': 'positions' }, + { + '1': 'summary', + '3': 6, + '4': 1, + '5': 11, + '6': '.alt.v1.BacktestSummaryMetrics', + '10': 'summary' + }, + { + '1': 'equity_curve', + '3': 7, + '4': 3, + '5': 11, + '6': '.alt.v1.BacktestEquityPoint', + '10': 'equityCurve' + }, ], }; @@ -297,7 +373,9 @@ final $typed_data.Uint8List backtestResultDescriptor = $convert.base64Decode( 'gYAiABKAsyDS5hbHQudjEuUHJpY2VSDHN0YXJ0aW5nQ2FzaBIyCg1lbmRpbmdfZXF1aXR5GAMg' 'ASgLMg0uYWx0LnYxLlByaWNlUgxlbmRpbmdFcXVpdHkSLQoGdHJhZGVzGAQgAygLMhUuYWx0Ln' 'YxLkJhY2t0ZXN0VHJhZGVSBnRyYWRlcxI2Cglwb3NpdGlvbnMYBSADKAsyGC5hbHQudjEuQmFj' - 'a3Rlc3RQb3NpdGlvblIJcG9zaXRpb25z'); + 'a3Rlc3RQb3NpdGlvblIJcG9zaXRpb25zEjgKB3N1bW1hcnkYBiABKAsyHi5hbHQudjEuQmFja3' + 'Rlc3RTdW1tYXJ5TWV0cmljc1IHc3VtbWFyeRI+CgxlcXVpdHlfY3VydmUYByADKAsyGy5hbHQu' + 'djEuQmFja3Rlc3RFcXVpdHlQb2ludFILZXF1aXR5Q3VydmU='); @$core.Deprecated('Use getBacktestResultRequestDescriptor instead') const GetBacktestResultRequest$json = { @@ -332,3 +410,122 @@ final $typed_data.Uint8List getBacktestResultResponseDescriptor = $convert.base64Decode( 'ChlHZXRCYWNrdGVzdFJlc3VsdFJlc3BvbnNlEi4KBnJlc3VsdBgBIAEoCzIWLmFsdC52MS5CYW' 'NrdGVzdFJlc3VsdFIGcmVzdWx0'); + +@$core.Deprecated('Use listBacktestRunsRequestDescriptor instead') +const ListBacktestRunsRequest$json = { + '1': 'ListBacktestRunsRequest', + '2': [ + { + '1': 'status', + '3': 1, + '4': 1, + '5': 14, + '6': '.alt.v1.BacktestRunStatus', + '10': 'status' + }, + ], +}; + +/// Descriptor for `ListBacktestRunsRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List listBacktestRunsRequestDescriptor = + $convert.base64Decode( + 'ChdMaXN0QmFja3Rlc3RSdW5zUmVxdWVzdBIxCgZzdGF0dXMYASABKA4yGS5hbHQudjEuQmFja3' + 'Rlc3RSdW5TdGF0dXNSBnN0YXR1cw=='); + +@$core.Deprecated('Use listBacktestRunsResponseDescriptor instead') +const ListBacktestRunsResponse$json = { + '1': 'ListBacktestRunsResponse', + '2': [ + { + '1': 'runs', + '3': 1, + '4': 3, + '5': 11, + '6': '.alt.v1.BacktestRun', + '10': 'runs' + }, + ], +}; + +/// Descriptor for `ListBacktestRunsResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List listBacktestRunsResponseDescriptor = + $convert.base64Decode( + 'ChhMaXN0QmFja3Rlc3RSdW5zUmVzcG9uc2USJwoEcnVucxgBIAMoCzITLmFsdC52MS5CYWNrdG' + 'VzdFJ1blIEcnVucw=='); + +@$core.Deprecated('Use getBacktestRunDetailRequestDescriptor instead') +const GetBacktestRunDetailRequest$json = { + '1': 'GetBacktestRunDetailRequest', + '2': [ + {'1': 'run_id', '3': 1, '4': 1, '5': 9, '10': 'runId'}, + ], +}; + +/// Descriptor for `GetBacktestRunDetailRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List getBacktestRunDetailRequestDescriptor = + $convert.base64Decode( + 'ChtHZXRCYWNrdGVzdFJ1bkRldGFpbFJlcXVlc3QSFQoGcnVuX2lkGAEgASgJUgVydW5JZA=='); + +@$core.Deprecated('Use getBacktestRunDetailResponseDescriptor instead') +const GetBacktestRunDetailResponse$json = { + '1': 'GetBacktestRunDetailResponse', + '2': [ + { + '1': 'run', + '3': 1, + '4': 1, + '5': 11, + '6': '.alt.v1.BacktestRun', + '10': 'run' + }, + { + '1': 'result', + '3': 2, + '4': 1, + '5': 11, + '6': '.alt.v1.BacktestResult', + '10': 'result' + }, + ], +}; + +/// Descriptor for `GetBacktestRunDetailResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List getBacktestRunDetailResponseDescriptor = + $convert.base64Decode( + 'ChxHZXRCYWNrdGVzdFJ1bkRldGFpbFJlc3BvbnNlEiUKA3J1bhgBIAEoCzITLmFsdC52MS5CYW' + 'NrdGVzdFJ1blIDcnVuEi4KBnJlc3VsdBgCIAEoCzIWLmFsdC52MS5CYWNrdGVzdFJlc3VsdFIG' + 'cmVzdWx0'); + +@$core.Deprecated('Use compareBacktestRunsRequestDescriptor instead') +const CompareBacktestRunsRequest$json = { + '1': 'CompareBacktestRunsRequest', + '2': [ + {'1': 'run_ids', '3': 1, '4': 3, '5': 9, '10': 'runIds'}, + ], +}; + +/// Descriptor for `CompareBacktestRunsRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List compareBacktestRunsRequestDescriptor = + $convert.base64Decode( + 'ChpDb21wYXJlQmFja3Rlc3RSdW5zUmVxdWVzdBIXCgdydW5faWRzGAEgAygJUgZydW5JZHM='); + +@$core.Deprecated('Use compareBacktestRunsResponseDescriptor instead') +const CompareBacktestRunsResponse$json = { + '1': 'CompareBacktestRunsResponse', + '2': [ + { + '1': 'results', + '3': 1, + '4': 3, + '5': 11, + '6': '.alt.v1.BacktestResult', + '10': 'results' + }, + ], +}; + +/// Descriptor for `CompareBacktestRunsResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List compareBacktestRunsResponseDescriptor = + $convert.base64Decode( + 'ChtDb21wYXJlQmFja3Rlc3RSdW5zUmVzcG9uc2USMAoHcmVzdWx0cxgBIAMoCzIWLmFsdC52MS' + '5CYWNrdGVzdFJlc3VsdFIHcmVzdWx0cw=='); diff --git a/apps/client/test/contracts/alt_contracts_test.dart b/apps/client/test/contracts/alt_contracts_test.dart index 040a1ac..75dbde8 100644 --- a/apps/client/test/contracts/alt_contracts_test.dart +++ b/apps/client/test/contracts/alt_contracts_test.dart @@ -23,9 +23,15 @@ void main() { GetBacktestResultRequest(), GetBacktestResultResponse(), BacktestResult(), + ListBacktestRunsRequest(), + ListBacktestRunsResponse(), + GetBacktestRunDetailRequest(), + GetBacktestRunDetailResponse(), + CompareBacktestRunsRequest(), + CompareBacktestRunsResponse(), ]; - expect(parsers.length, equals(13)); + expect(parsers.length, equals(19)); for (final msg in expectedMessages) { final qualifiedName = msg.info_.qualifiedMessageName; diff --git a/packages/contracts/gen/go/alt/v1/backtest.pb.go b/packages/contracts/gen/go/alt/v1/backtest.pb.go index 0f0b3e8..75bd77b 100644 --- a/packages/contracts/gen/go/alt/v1/backtest.pb.go +++ b/packages/contracts/gen/go/alt/v1/backtest.pb.go @@ -543,20 +543,142 @@ func (x *BacktestPosition) GetLastPrice() *Price { return nil } -type BacktestResult struct { +type BacktestSummaryMetrics 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"` + StartingCash *Price `protobuf:"bytes,1,opt,name=starting_cash,json=startingCash,proto3" json:"starting_cash,omitempty"` + EndingEquity *Price `protobuf:"bytes,2,opt,name=ending_equity,json=endingEquity,proto3" json:"ending_equity,omitempty"` + TotalReturn *Decimal `protobuf:"bytes,3,opt,name=total_return,json=totalReturn,proto3" json:"total_return,omitempty"` + TradeCount int32 `protobuf:"varint,4,opt,name=trade_count,json=tradeCount,proto3" json:"trade_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BacktestSummaryMetrics) Reset() { + *x = BacktestSummaryMetrics{} + mi := &file_alt_v1_backtest_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BacktestSummaryMetrics) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BacktestSummaryMetrics) ProtoMessage() {} + +func (x *BacktestSummaryMetrics) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[8] + 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 BacktestSummaryMetrics.ProtoReflect.Descriptor instead. +func (*BacktestSummaryMetrics) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{8} +} + +func (x *BacktestSummaryMetrics) GetStartingCash() *Price { + if x != nil { + return x.StartingCash + } + return nil +} + +func (x *BacktestSummaryMetrics) GetEndingEquity() *Price { + if x != nil { + return x.EndingEquity + } + return nil +} + +func (x *BacktestSummaryMetrics) GetTotalReturn() *Decimal { + if x != nil { + return x.TotalReturn + } + return nil +} + +func (x *BacktestSummaryMetrics) GetTradeCount() int32 { + if x != nil { + return x.TradeCount + } + return 0 +} + +type BacktestEquityPoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + TimestampUnixMs int64 `protobuf:"varint,1,opt,name=timestamp_unix_ms,json=timestampUnixMs,proto3" json:"timestamp_unix_ms,omitempty"` + Equity *Price `protobuf:"bytes,2,opt,name=equity,proto3" json:"equity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BacktestEquityPoint) Reset() { + *x = BacktestEquityPoint{} + mi := &file_alt_v1_backtest_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BacktestEquityPoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BacktestEquityPoint) ProtoMessage() {} + +func (x *BacktestEquityPoint) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[9] + 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 BacktestEquityPoint.ProtoReflect.Descriptor instead. +func (*BacktestEquityPoint) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{9} +} + +func (x *BacktestEquityPoint) GetTimestampUnixMs() int64 { + if x != nil { + return x.TimestampUnixMs + } + return 0 +} + +func (x *BacktestEquityPoint) GetEquity() *Price { + if x != nil { + return x.Equity + } + 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"` + Summary *BacktestSummaryMetrics `protobuf:"bytes,6,opt,name=summary,proto3" json:"summary,omitempty"` + EquityCurve []*BacktestEquityPoint `protobuf:"bytes,7,rep,name=equity_curve,json=equityCurve,proto3" json:"equity_curve,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *BacktestResult) Reset() { *x = BacktestResult{} - 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) } @@ -568,7 +690,7 @@ func (x *BacktestResult) String() string { func (*BacktestResult) ProtoMessage() {} func (x *BacktestResult) 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 { @@ -581,7 +703,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{8} + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{10} } func (x *BacktestResult) GetRunId() string { @@ -619,6 +741,20 @@ func (x *BacktestResult) GetPositions() []*BacktestPosition { return nil } +func (x *BacktestResult) GetSummary() *BacktestSummaryMetrics { + if x != nil { + return x.Summary + } + return nil +} + +func (x *BacktestResult) GetEquityCurve() []*BacktestEquityPoint { + if x != nil { + return x.EquityCurve + } + 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"` @@ -628,7 +764,7 @@ type GetBacktestResultRequest struct { func (x *GetBacktestResultRequest) Reset() { *x = GetBacktestResultRequest{} - mi := &file_alt_v1_backtest_proto_msgTypes[9] + mi := &file_alt_v1_backtest_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -640,7 +776,7 @@ func (x *GetBacktestResultRequest) String() string { func (*GetBacktestResultRequest) ProtoMessage() {} func (x *GetBacktestResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_alt_v1_backtest_proto_msgTypes[9] + mi := &file_alt_v1_backtest_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -653,7 +789,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{9} + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{11} } func (x *GetBacktestResultRequest) GetRunId() string { @@ -672,7 +808,7 @@ type GetBacktestResultResponse struct { func (x *GetBacktestResultResponse) Reset() { *x = GetBacktestResultResponse{} - mi := &file_alt_v1_backtest_proto_msgTypes[10] + mi := &file_alt_v1_backtest_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -684,7 +820,7 @@ func (x *GetBacktestResultResponse) String() string { func (*GetBacktestResultResponse) ProtoMessage() {} func (x *GetBacktestResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_alt_v1_backtest_proto_msgTypes[10] + mi := &file_alt_v1_backtest_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -697,7 +833,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{10} + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{12} } func (x *GetBacktestResultResponse) GetResult() *BacktestResult { @@ -707,6 +843,278 @@ func (x *GetBacktestResultResponse) GetResult() *BacktestResult { return nil } +type ListBacktestRunsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status BacktestRunStatus `protobuf:"varint,1,opt,name=status,proto3,enum=alt.v1.BacktestRunStatus" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBacktestRunsRequest) Reset() { + *x = ListBacktestRunsRequest{} + mi := &file_alt_v1_backtest_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBacktestRunsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBacktestRunsRequest) ProtoMessage() {} + +func (x *ListBacktestRunsRequest) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[13] + 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 ListBacktestRunsRequest.ProtoReflect.Descriptor instead. +func (*ListBacktestRunsRequest) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{13} +} + +func (x *ListBacktestRunsRequest) GetStatus() BacktestRunStatus { + if x != nil { + return x.Status + } + return BacktestRunStatus_BACKTEST_RUN_STATUS_UNSPECIFIED +} + +type ListBacktestRunsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Runs []*BacktestRun `protobuf:"bytes,1,rep,name=runs,proto3" json:"runs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListBacktestRunsResponse) Reset() { + *x = ListBacktestRunsResponse{} + mi := &file_alt_v1_backtest_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListBacktestRunsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListBacktestRunsResponse) ProtoMessage() {} + +func (x *ListBacktestRunsResponse) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[14] + 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 ListBacktestRunsResponse.ProtoReflect.Descriptor instead. +func (*ListBacktestRunsResponse) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{14} +} + +func (x *ListBacktestRunsResponse) GetRuns() []*BacktestRun { + if x != nil { + return x.Runs + } + return nil +} + +type GetBacktestRunDetailRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBacktestRunDetailRequest) Reset() { + *x = GetBacktestRunDetailRequest{} + mi := &file_alt_v1_backtest_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBacktestRunDetailRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBacktestRunDetailRequest) ProtoMessage() {} + +func (x *GetBacktestRunDetailRequest) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[15] + 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 GetBacktestRunDetailRequest.ProtoReflect.Descriptor instead. +func (*GetBacktestRunDetailRequest) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{15} +} + +func (x *GetBacktestRunDetailRequest) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +type GetBacktestRunDetailResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Run *BacktestRun `protobuf:"bytes,1,opt,name=run,proto3" json:"run,omitempty"` + Result *BacktestResult `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBacktestRunDetailResponse) Reset() { + *x = GetBacktestRunDetailResponse{} + mi := &file_alt_v1_backtest_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBacktestRunDetailResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBacktestRunDetailResponse) ProtoMessage() {} + +func (x *GetBacktestRunDetailResponse) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[16] + 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 GetBacktestRunDetailResponse.ProtoReflect.Descriptor instead. +func (*GetBacktestRunDetailResponse) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{16} +} + +func (x *GetBacktestRunDetailResponse) GetRun() *BacktestRun { + if x != nil { + return x.Run + } + return nil +} + +func (x *GetBacktestRunDetailResponse) GetResult() *BacktestResult { + if x != nil { + return x.Result + } + return nil +} + +type CompareBacktestRunsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunIds []string `protobuf:"bytes,1,rep,name=run_ids,json=runIds,proto3" json:"run_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompareBacktestRunsRequest) Reset() { + *x = CompareBacktestRunsRequest{} + mi := &file_alt_v1_backtest_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompareBacktestRunsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompareBacktestRunsRequest) ProtoMessage() {} + +func (x *CompareBacktestRunsRequest) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[17] + 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 CompareBacktestRunsRequest.ProtoReflect.Descriptor instead. +func (*CompareBacktestRunsRequest) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{17} +} + +func (x *CompareBacktestRunsRequest) GetRunIds() []string { + if x != nil { + return x.RunIds + } + return nil +} + +type CompareBacktestRunsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*BacktestResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompareBacktestRunsResponse) Reset() { + *x = CompareBacktestRunsResponse{} + mi := &file_alt_v1_backtest_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompareBacktestRunsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompareBacktestRunsResponse) ProtoMessage() {} + +func (x *CompareBacktestRunsResponse) ProtoReflect() protoreflect.Message { + mi := &file_alt_v1_backtest_proto_msgTypes[18] + 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 CompareBacktestRunsResponse.ProtoReflect.Descriptor instead. +func (*CompareBacktestRunsResponse) Descriptor() ([]byte, []int) { + return file_alt_v1_backtest_proto_rawDescGZIP(), []int{18} +} + +func (x *CompareBacktestRunsResponse) GetResults() []*BacktestResult { + if x != nil { + return x.Results + } + return nil +} + var File_alt_v1_backtest_proto protoreflect.FileDescriptor const file_alt_v1_backtest_proto_rawDesc = "" + @@ -745,17 +1153,41 @@ const file_alt_v1_backtest_proto_rawDesc = "" + "\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" + + "last_price\x18\x03 \x01(\v2\r.alt.v1.PriceR\tlastPrice\"\xd5\x01\n" + + "\x16BacktestSummaryMetrics\x122\n" + + "\rstarting_cash\x18\x01 \x01(\v2\r.alt.v1.PriceR\fstartingCash\x122\n" + + "\rending_equity\x18\x02 \x01(\v2\r.alt.v1.PriceR\fendingEquity\x122\n" + + "\ftotal_return\x18\x03 \x01(\v2\x0f.alt.v1.DecimalR\vtotalReturn\x12\x1f\n" + + "\vtrade_count\x18\x04 \x01(\x05R\n" + + "tradeCount\"h\n" + + "\x13BacktestEquityPoint\x12*\n" + + "\x11timestamp_unix_ms\x18\x01 \x01(\x03R\x0ftimestampUnixMs\x12%\n" + + "\x06equity\x18\x02 \x01(\v2\r.alt.v1.PriceR\x06equity\"\xf0\x02\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\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" + + "\tpositions\x18\x05 \x03(\v2\x18.alt.v1.BacktestPositionR\tpositions\x128\n" + + "\asummary\x18\x06 \x01(\v2\x1e.alt.v1.BacktestSummaryMetricsR\asummary\x12>\n" + + "\fequity_curve\x18\a \x03(\v2\x1b.alt.v1.BacktestEquityPointR\vequityCurve\"1\n" + "\x18GetBacktestResultRequest\x12\x15\n" + "\x06run_id\x18\x01 \x01(\tR\x05runId\"K\n" + "\x19GetBacktestResultResponse\x12.\n" + - "\x06result\x18\x01 \x01(\v2\x16.alt.v1.BacktestResultR\x06result*\xdf\x01\n" + + "\x06result\x18\x01 \x01(\v2\x16.alt.v1.BacktestResultR\x06result\"L\n" + + "\x17ListBacktestRunsRequest\x121\n" + + "\x06status\x18\x01 \x01(\x0e2\x19.alt.v1.BacktestRunStatusR\x06status\"C\n" + + "\x18ListBacktestRunsResponse\x12'\n" + + "\x04runs\x18\x01 \x03(\v2\x13.alt.v1.BacktestRunR\x04runs\"4\n" + + "\x1bGetBacktestRunDetailRequest\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\"u\n" + + "\x1cGetBacktestRunDetailResponse\x12%\n" + + "\x03run\x18\x01 \x01(\v2\x13.alt.v1.BacktestRunR\x03run\x12.\n" + + "\x06result\x18\x02 \x01(\v2\x16.alt.v1.BacktestResultR\x06result\"5\n" + + "\x1aCompareBacktestRunsRequest\x12\x17\n" + + "\arun_ids\x18\x01 \x03(\tR\x06runIds\"O\n" + + "\x1bCompareBacktestRunsResponse\x120\n" + + "\aresults\x18\x01 \x03(\v2\x16.alt.v1.BacktestResultR\aresults*\xdf\x01\n" + "\x11BacktestRunStatus\x12#\n" + "\x1fBACKTEST_RUN_STATUS_UNSPECIFIED\x10\x00\x12\x1f\n" + "\x1bBACKTEST_RUN_STATUS_PENDING\x10\x01\x12\x1f\n" + @@ -777,47 +1209,67 @@ 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, 11) +var file_alt_v1_backtest_proto_msgTypes = make([]protoimpl.MessageInfo, 19) var file_alt_v1_backtest_proto_goTypes = []any{ - (BacktestRunStatus)(0), // 0: alt.v1.BacktestRunStatus - (*BacktestRunSpec)(nil), // 1: alt.v1.BacktestRunSpec - (*BacktestRun)(nil), // 2: alt.v1.BacktestRun - (*StartBacktestRequest)(nil), // 3: alt.v1.StartBacktestRequest - (*StartBacktestResponse)(nil), // 4: alt.v1.StartBacktestResponse - (*GetBacktestRunRequest)(nil), // 5: alt.v1.GetBacktestRunRequest - (*GetBacktestRunResponse)(nil), // 6: alt.v1.GetBacktestRunResponse - (*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 + (BacktestRunStatus)(0), // 0: alt.v1.BacktestRunStatus + (*BacktestRunSpec)(nil), // 1: alt.v1.BacktestRunSpec + (*BacktestRun)(nil), // 2: alt.v1.BacktestRun + (*StartBacktestRequest)(nil), // 3: alt.v1.StartBacktestRequest + (*StartBacktestResponse)(nil), // 4: alt.v1.StartBacktestResponse + (*GetBacktestRunRequest)(nil), // 5: alt.v1.GetBacktestRunRequest + (*GetBacktestRunResponse)(nil), // 6: alt.v1.GetBacktestRunResponse + (*BacktestTrade)(nil), // 7: alt.v1.BacktestTrade + (*BacktestPosition)(nil), // 8: alt.v1.BacktestPosition + (*BacktestSummaryMetrics)(nil), // 9: alt.v1.BacktestSummaryMetrics + (*BacktestEquityPoint)(nil), // 10: alt.v1.BacktestEquityPoint + (*BacktestResult)(nil), // 11: alt.v1.BacktestResult + (*GetBacktestResultRequest)(nil), // 12: alt.v1.GetBacktestResultRequest + (*GetBacktestResultResponse)(nil), // 13: alt.v1.GetBacktestResultResponse + (*ListBacktestRunsRequest)(nil), // 14: alt.v1.ListBacktestRunsRequest + (*ListBacktestRunsResponse)(nil), // 15: alt.v1.ListBacktestRunsResponse + (*GetBacktestRunDetailRequest)(nil), // 16: alt.v1.GetBacktestRunDetailRequest + (*GetBacktestRunDetailResponse)(nil), // 17: alt.v1.GetBacktestRunDetailResponse + (*CompareBacktestRunsRequest)(nil), // 18: alt.v1.CompareBacktestRunsRequest + (*CompareBacktestRunsResponse)(nil), // 19: alt.v1.CompareBacktestRunsResponse + (Market)(0), // 20: alt.v1.Market + (Timeframe)(0), // 21: alt.v1.Timeframe + (*Quantity)(nil), // 22: alt.v1.Quantity + (*Price)(nil), // 23: alt.v1.Price + (*Decimal)(nil), // 24: alt.v1.Decimal } var file_alt_v1_backtest_proto_depIdxs = []int32{ - 12, // 0: alt.v1.BacktestRunSpec.market:type_name -> alt.v1.Market - 13, // 1: alt.v1.BacktestRunSpec.timeframe:type_name -> alt.v1.Timeframe + 20, // 0: alt.v1.BacktestRunSpec.market:type_name -> alt.v1.Market + 21, // 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 - 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 + 22, // 7: alt.v1.BacktestTrade.quantity:type_name -> alt.v1.Quantity + 23, // 8: alt.v1.BacktestTrade.price:type_name -> alt.v1.Price + 22, // 9: alt.v1.BacktestPosition.quantity:type_name -> alt.v1.Quantity + 23, // 10: alt.v1.BacktestPosition.last_price:type_name -> alt.v1.Price + 23, // 11: alt.v1.BacktestSummaryMetrics.starting_cash:type_name -> alt.v1.Price + 23, // 12: alt.v1.BacktestSummaryMetrics.ending_equity:type_name -> alt.v1.Price + 24, // 13: alt.v1.BacktestSummaryMetrics.total_return:type_name -> alt.v1.Decimal + 23, // 14: alt.v1.BacktestEquityPoint.equity:type_name -> alt.v1.Price + 23, // 15: alt.v1.BacktestResult.starting_cash:type_name -> alt.v1.Price + 23, // 16: alt.v1.BacktestResult.ending_equity:type_name -> alt.v1.Price + 7, // 17: alt.v1.BacktestResult.trades:type_name -> alt.v1.BacktestTrade + 8, // 18: alt.v1.BacktestResult.positions:type_name -> alt.v1.BacktestPosition + 9, // 19: alt.v1.BacktestResult.summary:type_name -> alt.v1.BacktestSummaryMetrics + 10, // 20: alt.v1.BacktestResult.equity_curve:type_name -> alt.v1.BacktestEquityPoint + 11, // 21: alt.v1.GetBacktestResultResponse.result:type_name -> alt.v1.BacktestResult + 0, // 22: alt.v1.ListBacktestRunsRequest.status:type_name -> alt.v1.BacktestRunStatus + 2, // 23: alt.v1.ListBacktestRunsResponse.runs:type_name -> alt.v1.BacktestRun + 2, // 24: alt.v1.GetBacktestRunDetailResponse.run:type_name -> alt.v1.BacktestRun + 11, // 25: alt.v1.GetBacktestRunDetailResponse.result:type_name -> alt.v1.BacktestResult + 11, // 26: alt.v1.CompareBacktestRunsResponse.results:type_name -> alt.v1.BacktestResult + 27, // [27:27] is the sub-list for method output_type + 27, // [27:27] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_alt_v1_backtest_proto_init() } @@ -833,7 +1285,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: 11, + NumMessages: 19, NumExtensions: 0, NumServices: 0, }, diff --git a/packages/contracts/proto/alt/v1/backtest.proto b/packages/contracts/proto/alt/v1/backtest.proto index 651f1f3..29aa043 100644 --- a/packages/contracts/proto/alt/v1/backtest.proto +++ b/packages/contracts/proto/alt/v1/backtest.proto @@ -62,12 +62,26 @@ message BacktestPosition { Price last_price = 3; } +message BacktestSummaryMetrics { + Price starting_cash = 1; + Price ending_equity = 2; + Decimal total_return = 3; + int32 trade_count = 4; +} + +message BacktestEquityPoint { + int64 timestamp_unix_ms = 1; + Price equity = 2; +} + message BacktestResult { string run_id = 1; Price starting_cash = 2; Price ending_equity = 3; repeated BacktestTrade trades = 4; repeated BacktestPosition positions = 5; + BacktestSummaryMetrics summary = 6; + repeated BacktestEquityPoint equity_curve = 7; } message GetBacktestResultRequest { @@ -78,3 +92,28 @@ message GetBacktestResultResponse { BacktestResult result = 1; } +message ListBacktestRunsRequest { + BacktestRunStatus status = 1; +} + +message ListBacktestRunsResponse { + repeated BacktestRun runs = 1; +} + +message GetBacktestRunDetailRequest { + string run_id = 1; +} + +message GetBacktestRunDetailResponse { + BacktestRun run = 1; + BacktestResult result = 2; +} + +message CompareBacktestRunsRequest { + repeated string run_ids = 1; +} + +message CompareBacktestRunsResponse { + repeated BacktestResult results = 1; +} + diff --git a/packages/domain/backtest/types.go b/packages/domain/backtest/types.go index 46bc8fa..ea0fa55 100644 --- a/packages/domain/backtest/types.go +++ b/packages/domain/backtest/types.go @@ -52,12 +52,42 @@ type PositionSummary struct { LastPrice market.Price } +type SummaryMetrics struct { + StartingCash market.Price + EndingEquity market.Price + TotalReturn market.Decimal + TradeCount int +} + +type EquityPoint struct { + Timestamp time.Time + Equity market.Price +} + type Result struct { RunID RunID StartingCash market.Price EndingEquity market.Price Trades []TradeSummary Positions []PositionSummary + Summary SummaryMetrics + EquityCurve []EquityPoint +} + +// TotalReturn computes (endingEquity - startingCash) / startingCash as a decimal +// string. Starting cash that is zero or invalid yields "0" so callers never divide +// by zero or surface float artifacts. +func TotalReturn(startingCash, endingEquity market.Price) market.Decimal { + start, err := decimalRat(startingCash.Amount) + if err != nil || start.Sign() == 0 { + return market.Decimal{Value: "0"} + } + end, err := decimalRat(endingEquity.Amount) + if err != nil { + return market.Decimal{Value: "0"} + } + ratio := new(big.Rat).Quo(new(big.Rat).Sub(end, start), start) + return decimalValue(ratio) } type Strategy interface { diff --git a/packages/domain/backtest/types_test.go b/packages/domain/backtest/types_test.go index 4003f3a..77eba63 100644 --- a/packages/domain/backtest/types_test.go +++ b/packages/domain/backtest/types_test.go @@ -221,3 +221,55 @@ func TestResultCarriesTradeAndPositionSummary(t *testing.T) { t.Errorf("Position LastPrice mismatch: got %v", pos.LastPrice.Amount.Value) } } + +func TestResultCarriesAnalysisSummaryAndEquityCurve(t *testing.T) { + t1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC) + res := Result{ + RunID: RunID("run-summary"), + StartingCash: price("1000"), + EndingEquity: price("1500"), + Summary: SummaryMetrics{ + StartingCash: price("1000"), + EndingEquity: price("1500"), + TotalReturn: TotalReturn(price("1000"), price("1500")), + TradeCount: 3, + }, + EquityCurve: []EquityPoint{ + {Timestamp: t1, Equity: price("1200")}, + {Timestamp: t2, Equity: price("1500")}, + }, + } + + if res.Summary.TotalReturn.Value != "0.5" { + t.Errorf("Summary TotalReturn mismatch: got %q, want 0.5", res.Summary.TotalReturn.Value) + } + if res.Summary.TradeCount != 3 { + t.Errorf("Summary TradeCount mismatch: got %d, want 3", res.Summary.TradeCount) + } + if res.Summary.StartingCash.Amount.Value != "1000" || res.Summary.EndingEquity.Amount.Value != "1500" { + t.Errorf("Summary cash/equity mismatch: %+v", res.Summary) + } + + if len(res.EquityCurve) != 2 { + t.Fatalf("EquityCurve length mismatch: got %d, want 2", len(res.EquityCurve)) + } + if !res.EquityCurve[0].Timestamp.Equal(t1) || res.EquityCurve[0].Equity.Amount.Value != "1200" { + t.Errorf("EquityCurve point 0 mismatch: %+v", res.EquityCurve[0]) + } + if !res.EquityCurve[1].Timestamp.Equal(t2) || res.EquityCurve[1].Equity.Amount.Value != "1500" { + t.Errorf("EquityCurve point 1 mismatch: %+v", res.EquityCurve[1]) + } +} + +func TestTotalReturnHandlesZeroAndInvalidStartingCash(t *testing.T) { + if got := TotalReturn(price("0"), price("1500")); got.Value != "0" { + t.Errorf("zero starting cash: got %q, want 0", got.Value) + } + if got := TotalReturn(market.Price{Amount: market.Decimal{Value: "bad"}}, price("1500")); got.Value != "0" { + t.Errorf("invalid starting cash: got %q, want 0", got.Value) + } + if got := TotalReturn(price("10000000"), price("10000300")); got.Value != "0.00003" { + t.Errorf("fixture total return: got %q, want 0.00003", got.Value) + } +} diff --git a/services/api/internal/contracts/parser_map.go b/services/api/internal/contracts/parser_map.go index 6bd1ba9..de1f6d2 100644 --- a/services/api/internal/contracts/parser_map.go +++ b/services/api/internal/contracts/parser_map.go @@ -3,26 +3,32 @@ package contracts import ( "google.golang.org/protobuf/proto" - protoSocket "git.toki-labs.com/toki/proto-socket/go" altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" + protoSocket "git.toki-labs.com/toki/proto-socket/go" ) // ParserMap returns a fresh ParserMap populated with all ALT API messages. func ParserMap() protoSocket.ParserMap { return protoSocket.ParserMap{ - protoSocket.TypeNameOf(&altv1.HelloRequest{}): parserFor(func() proto.Message { return &altv1.HelloRequest{} }), - protoSocket.TypeNameOf(&altv1.HelloResponse{}): parserFor(func() proto.Message { return &altv1.HelloResponse{} }), - protoSocket.TypeNameOf(&altv1.ListInstrumentsRequest{}): parserFor(func() proto.Message { return &altv1.ListInstrumentsRequest{} }), - protoSocket.TypeNameOf(&altv1.ListInstrumentsResponse{}): parserFor(func() proto.Message { return &altv1.ListInstrumentsResponse{} }), - protoSocket.TypeNameOf(&altv1.ListBarsRequest{}): parserFor(func() proto.Message { return &altv1.ListBarsRequest{} }), - protoSocket.TypeNameOf(&altv1.ListBarsResponse{}): parserFor(func() proto.Message { return &altv1.ListBarsResponse{} }), - protoSocket.TypeNameOf(&altv1.StartBacktestRequest{}): parserFor(func() proto.Message { return &altv1.StartBacktestRequest{} }), - protoSocket.TypeNameOf(&altv1.StartBacktestResponse{}): parserFor(func() proto.Message { return &altv1.StartBacktestResponse{} }), - protoSocket.TypeNameOf(&altv1.GetBacktestRunRequest{}): parserFor(func() proto.Message { return &altv1.GetBacktestRunRequest{} }), - protoSocket.TypeNameOf(&altv1.GetBacktestRunResponse{}): parserFor(func() proto.Message { return &altv1.GetBacktestRunResponse{} }), - protoSocket.TypeNameOf(&altv1.GetBacktestResultRequest{}): parserFor(func() proto.Message { return &altv1.GetBacktestResultRequest{} }), - protoSocket.TypeNameOf(&altv1.GetBacktestResultResponse{}): parserFor(func() proto.Message { return &altv1.GetBacktestResultResponse{} }), - protoSocket.TypeNameOf(&altv1.BacktestResult{}): parserFor(func() proto.Message { return &altv1.BacktestResult{} }), + protoSocket.TypeNameOf(&altv1.HelloRequest{}): parserFor(func() proto.Message { return &altv1.HelloRequest{} }), + protoSocket.TypeNameOf(&altv1.HelloResponse{}): parserFor(func() proto.Message { return &altv1.HelloResponse{} }), + protoSocket.TypeNameOf(&altv1.ListInstrumentsRequest{}): parserFor(func() proto.Message { return &altv1.ListInstrumentsRequest{} }), + protoSocket.TypeNameOf(&altv1.ListInstrumentsResponse{}): parserFor(func() proto.Message { return &altv1.ListInstrumentsResponse{} }), + protoSocket.TypeNameOf(&altv1.ListBarsRequest{}): parserFor(func() proto.Message { return &altv1.ListBarsRequest{} }), + protoSocket.TypeNameOf(&altv1.ListBarsResponse{}): parserFor(func() proto.Message { return &altv1.ListBarsResponse{} }), + protoSocket.TypeNameOf(&altv1.StartBacktestRequest{}): parserFor(func() proto.Message { return &altv1.StartBacktestRequest{} }), + protoSocket.TypeNameOf(&altv1.StartBacktestResponse{}): parserFor(func() proto.Message { return &altv1.StartBacktestResponse{} }), + protoSocket.TypeNameOf(&altv1.GetBacktestRunRequest{}): parserFor(func() proto.Message { return &altv1.GetBacktestRunRequest{} }), + protoSocket.TypeNameOf(&altv1.GetBacktestRunResponse{}): parserFor(func() proto.Message { return &altv1.GetBacktestRunResponse{} }), + protoSocket.TypeNameOf(&altv1.GetBacktestResultRequest{}): parserFor(func() proto.Message { return &altv1.GetBacktestResultRequest{} }), + protoSocket.TypeNameOf(&altv1.GetBacktestResultResponse{}): parserFor(func() proto.Message { return &altv1.GetBacktestResultResponse{} }), + protoSocket.TypeNameOf(&altv1.BacktestResult{}): parserFor(func() proto.Message { return &altv1.BacktestResult{} }), + protoSocket.TypeNameOf(&altv1.ListBacktestRunsRequest{}): parserFor(func() proto.Message { return &altv1.ListBacktestRunsRequest{} }), + protoSocket.TypeNameOf(&altv1.ListBacktestRunsResponse{}): parserFor(func() proto.Message { return &altv1.ListBacktestRunsResponse{} }), + protoSocket.TypeNameOf(&altv1.GetBacktestRunDetailRequest{}): parserFor(func() proto.Message { return &altv1.GetBacktestRunDetailRequest{} }), + protoSocket.TypeNameOf(&altv1.GetBacktestRunDetailResponse{}): parserFor(func() proto.Message { return &altv1.GetBacktestRunDetailResponse{} }), + protoSocket.TypeNameOf(&altv1.CompareBacktestRunsRequest{}): parserFor(func() proto.Message { return &altv1.CompareBacktestRunsRequest{} }), + protoSocket.TypeNameOf(&altv1.CompareBacktestRunsResponse{}): parserFor(func() proto.Message { return &altv1.CompareBacktestRunsResponse{} }), } } diff --git a/services/api/internal/contracts/parser_map_test.go b/services/api/internal/contracts/parser_map_test.go index 3ee4dad..9e9ab72 100644 --- a/services/api/internal/contracts/parser_map_test.go +++ b/services/api/internal/contracts/parser_map_test.go @@ -26,6 +26,12 @@ func TestParserMapIncludesAltMessages(t *testing.T) { &altv1.GetBacktestResultRequest{}, &altv1.GetBacktestResultResponse{}, &altv1.BacktestResult{}, + &altv1.ListBacktestRunsRequest{}, + &altv1.ListBacktestRunsResponse{}, + &altv1.GetBacktestRunDetailRequest{}, + &altv1.GetBacktestRunDetailResponse{}, + &altv1.CompareBacktestRunsRequest{}, + &altv1.CompareBacktestRunsResponse{}, } for _, msg := range messages { diff --git a/services/worker/internal/backtest/engine.go b/services/worker/internal/backtest/engine.go index 145b764..51d19dc 100644 --- a/services/worker/internal/backtest/engine.go +++ b/services/worker/internal/backtest/engine.go @@ -70,6 +70,7 @@ func (e *Engine) Execute(ctx context.Context, run backtest.Run) error { portfolio := backtest.NewPortfolioState(startingCash) var history []market.Bar var trades []backtest.TradeSummary + var equityCurve []backtest.EquityPoint for _, bar := range bars { input := backtest.StrategyInput{ @@ -115,6 +116,14 @@ func (e *Engine) Execute(ctx context.Context, run backtest.Run) error { } } + // Record one equity point per processed bar after marking the current close + // so the curve is deterministic and ordered by bar timestamp. + equity, err := portfolio.Equity() + if err != nil { + return fmt.Errorf("failed to calculate equity at %s: %w", bar.Timestamp, err) + } + equityCurve = append(equityCurve, backtest.EquityPoint{Timestamp: bar.Timestamp, Equity: equity}) + history = append(history, bar) } @@ -144,6 +153,13 @@ func (e *Engine) Execute(ctx context.Context, run backtest.Run) error { EndingEquity: endingEquity, Trades: trades, Positions: positions, + Summary: backtest.SummaryMetrics{ + StartingCash: startingCash, + EndingEquity: endingEquity, + TotalReturn: backtest.TotalReturn(startingCash, endingEquity), + TradeCount: len(trades), + }, + EquityCurve: equityCurve, } if e.resultStore != nil { diff --git a/services/worker/internal/backtest/fixture_test.go b/services/worker/internal/backtest/fixture_test.go index 4b3ae95..8ac1c3b 100644 --- a/services/worker/internal/backtest/fixture_test.go +++ b/services/worker/internal/backtest/fixture_test.go @@ -257,4 +257,49 @@ func TestEngineStoresAndQueriesFixtureResult(t *testing.T) { if pos.InstrumentID != instID || pos.Quantity.Amount.Value != "1" || pos.LastPrice.Amount.Value != "1200" { t.Errorf("unexpected position: %+v", pos) } + + // Verify derived summary metrics. + if res.Summary.StartingCash.Amount.Value != "10000000" { + t.Errorf("summary starting cash: got %s, want 10000000", res.Summary.StartingCash.Amount.Value) + } + if res.Summary.EndingEquity.Amount.Value != "10000300" { + t.Errorf("summary ending equity: got %s, want 10000300", res.Summary.EndingEquity.Amount.Value) + } + if res.Summary.TradeCount != 2 { + t.Errorf("summary trade count: got %d, want 2", res.Summary.TradeCount) + } + // (10000300 - 10000000) / 10000000 = 0.00003 + if res.Summary.TotalReturn.Value != "0.00003" { + t.Errorf("summary total return: got %s, want 0.00003", res.Summary.TotalReturn.Value) + } + + // Verify deterministic equity curve: one point per bar, ordered by timestamp. + // Day 1: cash 9,998,000 + 2*1000 = 10,000,000 + // Day 2: cash 9,999,100 + 1*1100 = 10,000,200 + // Day 3: cash 9,999,100 + 1*1200 = 10,000,300 + if len(res.EquityCurve) != 3 { + t.Fatalf("equity curve length: got %d, want 3", len(res.EquityCurve)) + } + wantCurve := []struct { + ts time.Time + equity string + }{ + {bar1.Timestamp, "10000000"}, + {bar2.Timestamp, "10000200"}, + {bar3.Timestamp, "10000300"}, + } + for i, want := range wantCurve { + point := res.EquityCurve[i] + if !point.Timestamp.Equal(want.ts) { + t.Errorf("equity curve point %d timestamp: got %v, want %v", i, point.Timestamp, want.ts) + } + if point.Equity.Amount.Value != want.equity { + t.Errorf("equity curve point %d equity: got %s, want %s", i, point.Equity.Amount.Value, want.equity) + } + } + // Final equity curve point must match the reported ending equity. + finalPoint := res.EquityCurve[len(res.EquityCurve)-1] + if finalPoint.Equity.Amount.Value != res.EndingEquity.Amount.Value { + t.Errorf("final equity point %s does not match ending equity %s", finalPoint.Equity.Amount.Value, res.EndingEquity.Amount.Value) + } } diff --git a/services/worker/internal/storage/ports.go b/services/worker/internal/storage/ports.go index 0d8f22d..200e11c 100644 --- a/services/worker/internal/storage/ports.go +++ b/services/worker/internal/storage/ports.go @@ -32,3 +32,20 @@ type BacktestResultStore interface { UpsertResult(ctx context.Context, result backtest.Result) error GetResult(ctx context.Context, id backtest.RunID) (backtest.Result, error) } + +// RunDetail bundles a run with its analysis result for detail views. HasResult is +// false when the run exists but has not produced a result yet. +type RunDetail struct { + Run backtest.Run + Result backtest.Result + HasResult bool +} + +// BacktestAnalysisStore exposes list, detail, and compare read access for the +// analysis surface. It is separate from the execution-side run/result stores so the +// engine and job handlers keep a minimal write dependency. +type BacktestAnalysisStore interface { + ListRuns(ctx context.Context, status backtest.RunStatus) ([]backtest.Run, error) + GetRunDetail(ctx context.Context, id backtest.RunID) (RunDetail, error) + CompareResults(ctx context.Context, ids []backtest.RunID) ([]backtest.Result, error) +} diff --git a/services/worker/internal/storage/postgres/mapping.go b/services/worker/internal/storage/postgres/mapping.go index 5d45784..aa47c7a 100644 --- a/services/worker/internal/storage/postgres/mapping.go +++ b/services/worker/internal/storage/postgres/mapping.go @@ -233,14 +233,38 @@ func mapResultToParams(res backtest.Result) (sqlc.UpsertResultParams, error) { } } + // Summary defaults to "0" total return when callers persist a result without a + // computed summary so the NOT NULL numeric column never receives an empty value. + totalReturnValue := res.Summary.TotalReturn.Value + if totalReturnValue == "" { + totalReturnValue = "0" + } + totalReturn, err := stringToNumeric(totalReturnValue) + if err != nil { + return sqlc.UpsertResultParams{}, fmt.Errorf("invalid total return: %w", err) + } + + var equityCurveBytes []byte + if res.EquityCurve == nil { + equityCurveBytes = []byte("[]") + } else { + equityCurveBytes, err = json.Marshal(res.EquityCurve) + if err != nil { + return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal equity curve: %w", err) + } + } + return sqlc.UpsertResultParams{ RunID: string(res.RunID), StartingCashCurrency: string(res.StartingCash.Currency), StartingCashAmount: startingCashAmount, EndingEquityCurrency: string(res.EndingEquity.Currency), EndingEquityAmount: endingEquityAmount, + TotalReturn: totalReturn, + TradeCount: int32(res.Summary.TradeCount), Trades: tradesBytes, Positions: positionsBytes, + EquityCurve: equityCurveBytes, }, nil } @@ -268,17 +292,39 @@ func mapRowToResult(row sqlc.BacktestResult) (backtest.Result, error) { } } + totalReturnStr, err := numericToString(row.TotalReturn) + if err != nil { + return backtest.Result{}, fmt.Errorf("failed to parse total return: %w", err) + } + + var equityCurve []backtest.EquityPoint + if len(row.EquityCurve) > 0 { + if err := json.Unmarshal(row.EquityCurve, &equityCurve); err != nil { + return backtest.Result{}, fmt.Errorf("failed to unmarshal equity curve: %w", err) + } + } + + 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}, + } + return backtest.Result{ - RunID: backtest.RunID(row.RunID), - StartingCash: market.Price{ - Currency: market.Currency(row.StartingCashCurrency), - Amount: market.Decimal{Value: startingCashAmountStr}, + RunID: backtest.RunID(row.RunID), + StartingCash: startingCash, + EndingEquity: endingEquity, + Trades: trades, + Positions: positions, + Summary: backtest.SummaryMetrics{ + StartingCash: startingCash, + EndingEquity: endingEquity, + TotalReturn: market.Decimal{Value: totalReturnStr}, + TradeCount: int(row.TradeCount), }, - EndingEquity: market.Price{ - Currency: market.Currency(row.EndingEquityCurrency), - Amount: market.Decimal{Value: endingEquityAmountStr}, - }, - Trades: trades, - Positions: positions, + EquityCurve: equityCurve, }, nil } diff --git a/services/worker/internal/storage/postgres/mapping_test.go b/services/worker/internal/storage/postgres/mapping_test.go index 2047c85..d3badbe 100644 --- a/services/worker/internal/storage/postgres/mapping_test.go +++ b/services/worker/internal/storage/postgres/mapping_test.go @@ -145,6 +145,16 @@ func TestBacktestResultMappingRoundTrip(t *testing.T) { LastPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "52000"}}, }, }, + Summary: backtest.SummaryMetrics{ + StartingCash: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100000000"}}, + EndingEquity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "123456789.12"}}, + TotalReturn: market.Decimal{Value: "0.2345678912"}, + TradeCount: 1, + }, + EquityCurve: []backtest.EquityPoint{ + {Timestamp: now.Add(-2 * time.Hour), Equity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100000000"}}}, + {Timestamp: now.Add(-1 * time.Hour), Equity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "123456789.12"}}}, + }, } params, err := mapResultToParams(res) @@ -158,8 +168,11 @@ func TestBacktestResultMappingRoundTrip(t *testing.T) { StartingCashAmount: params.StartingCashAmount, EndingEquityCurrency: params.EndingEquityCurrency, EndingEquityAmount: params.EndingEquityAmount, + TotalReturn: params.TotalReturn, + TradeCount: params.TradeCount, Trades: params.Trades, Positions: params.Positions, + EquityCurve: params.EquityCurve, } mappedBack, err := mapRowToResult(row) @@ -192,6 +205,112 @@ func TestBacktestResultMappingRoundTrip(t *testing.T) { 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]) } + + // Summary round-trips, reconstructed from persisted columns. + if mappedBack.Summary.TotalReturn.Value != res.Summary.TotalReturn.Value { + t.Errorf("Summary TotalReturn mismatch: got %q, want %q", mappedBack.Summary.TotalReturn.Value, res.Summary.TotalReturn.Value) + } + if mappedBack.Summary.TradeCount != res.Summary.TradeCount { + t.Errorf("Summary TradeCount mismatch: got %d, want %d", mappedBack.Summary.TradeCount, res.Summary.TradeCount) + } + if mappedBack.Summary.StartingCash.Amount.Value != res.Summary.StartingCash.Amount.Value || mappedBack.Summary.EndingEquity.Amount.Value != res.Summary.EndingEquity.Amount.Value { + t.Errorf("Summary cash/equity mismatch: got %+v, want %+v", mappedBack.Summary, res.Summary) + } + + // Equity curve round-trips with order and timestamps preserved. + if len(mappedBack.EquityCurve) != len(res.EquityCurve) { + t.Fatalf("EquityCurve length mismatch: got %d, want %d", len(mappedBack.EquityCurve), len(res.EquityCurve)) + } + for i := range res.EquityCurve { + if !mappedBack.EquityCurve[i].Timestamp.Equal(res.EquityCurve[i].Timestamp) { + t.Errorf("EquityCurve[%d] timestamp mismatch: got %v, want %v", i, mappedBack.EquityCurve[i].Timestamp, res.EquityCurve[i].Timestamp) + } + if mappedBack.EquityCurve[i].Equity.Amount.Value != res.EquityCurve[i].Equity.Amount.Value { + t.Errorf("EquityCurve[%d] equity mismatch: got %q, want %q", i, mappedBack.EquityCurve[i].Equity.Amount.Value, res.EquityCurve[i].Equity.Amount.Value) + } + } +} + +func TestBacktestResultMappingDefaultsEmptySummary(t *testing.T) { + // A result persisted without a computed summary or equity curve must still map + // to valid NOT NULL columns ("0" total return, empty JSON arrays). + res := backtest.Result{ + RunID: backtest.RunID("run-empty"), + StartingCash: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100"}}, + EndingEquity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100"}}, + } + + params, err := mapResultToParams(res) + if err != nil { + t.Fatalf("failed to map result to params: %v", err) + } + if string(params.EquityCurve) != "[]" { + t.Errorf("expected empty equity curve JSON array, got %q", string(params.EquityCurve)) + } + + mappedBack, err := mapRowToResult(sqlc.BacktestResult{ + RunID: params.RunID, + StartingCashCurrency: params.StartingCashCurrency, + StartingCashAmount: params.StartingCashAmount, + EndingEquityCurrency: params.EndingEquityCurrency, + EndingEquityAmount: params.EndingEquityAmount, + TotalReturn: params.TotalReturn, + TradeCount: params.TradeCount, + Trades: params.Trades, + Positions: params.Positions, + EquityCurve: params.EquityCurve, + }) + if err != nil { + t.Fatalf("failed to map row back to result: %v", err) + } + if mappedBack.Summary.TotalReturn.Value != "0" { + t.Errorf("expected default total return 0, got %q", mappedBack.Summary.TotalReturn.Value) + } + if len(mappedBack.EquityCurve) != 0 { + t.Errorf("expected empty equity curve, got %d points", len(mappedBack.EquityCurve)) + } +} + +func TestBacktestRunListRowMapping(t *testing.T) { + // ListRuns returns sqlc.BacktestRun rows; verify they map to domain runs the same + // way single-run detail rows do, so list and detail surfaces stay consistent. + now := time.Now().UTC().Truncate(time.Microsecond) + run := backtest.Run{ + ID: backtest.RunID("run-list"), + Spec: backtest.RunSpec{ + StrategyID: backtest.StrategyID("strat-list"), + Market: market.MarketUS, + Timeframe: market.TimeframeDaily, + From: now.Add(-48 * time.Hour), + To: now, + }, + Status: backtest.RunStatusRunning, + CreatedAt: now.Add(-49 * time.Hour), + UpdatedAt: now, + } + + params, err := mapRunToParams(run) + if err != nil { + t.Fatalf("failed to map run to params: %v", err) + } + + mappedBack, err := mapRowToRun(sqlc.BacktestRun{ + ID: params.ID, + StrategyID: params.StrategyID, + Market: params.Market, + Timeframe: params.Timeframe, + FromTime: params.FromTime, + ToTime: params.ToTime, + Status: params.Status, + CreatedAt: params.CreatedAt, + UpdatedAt: params.UpdatedAt, + }) + if err != nil { + t.Fatalf("failed to map list row to run: %v", err) + } + if mappedBack.ID != run.ID || mappedBack.Status != run.Status || mappedBack.Spec.Market != run.Spec.Market { + t.Errorf("list run mapping mismatch: got %+v, want %+v", mappedBack, run) + } } func TestBacktestResultMappingRejectsInvalidDecimal(t *testing.T) { diff --git a/services/worker/internal/storage/postgres/migrate_test.go b/services/worker/internal/storage/postgres/migrate_test.go index 2bb03ad..209bb94 100644 --- a/services/worker/internal/storage/postgres/migrate_test.go +++ b/services/worker/internal/storage/postgres/migrate_test.go @@ -79,3 +79,29 @@ func TestMigrationStatementsAreNamed(t *testing.T) { t.Error("migrations FS has no entries") } } + +func TestAnalysisResultColumnsAddedInVersionedMigration(t *testing.T) { + // The analysis result columns must arrive through the v2 migration so databases + // already at version 1 receive them, rather than being edited into 000001. + content, err := migrationsFS.ReadFile("migrations/000002_backtest_analysis_results.up.sql") + if err != nil { + t.Fatalf("failed to read 000002 up migration: %v", err) + } + up := string(content) + for _, column := range []string{"total_return", "trade_count", "equity_curve"} { + if !strings.Contains(up, column) { + t.Errorf("000002 up migration missing column %q", column) + } + } + + // The originally applied 000001 migration must not carry the analysis columns. + backbone, err := migrationsFS.ReadFile("migrations/000001_worker_backbone.up.sql") + if err != nil { + t.Fatalf("failed to read 000001 up migration: %v", err) + } + for _, column := range []string{"total_return", "trade_count", "equity_curve"} { + if strings.Contains(string(backbone), column) { + t.Errorf("000001 up migration should not contain analysis column %q", column) + } + } +} diff --git a/services/worker/internal/storage/postgres/migrations/000002_backtest_analysis_results.down.sql b/services/worker/internal/storage/postgres/migrations/000002_backtest_analysis_results.down.sql new file mode 100644 index 0000000..c0b8817 --- /dev/null +++ b/services/worker/internal/storage/postgres/migrations/000002_backtest_analysis_results.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE backtest_results + DROP COLUMN IF EXISTS equity_curve, + DROP COLUMN IF EXISTS trade_count, + DROP COLUMN IF EXISTS total_return; diff --git a/services/worker/internal/storage/postgres/migrations/000002_backtest_analysis_results.up.sql b/services/worker/internal/storage/postgres/migrations/000002_backtest_analysis_results.up.sql new file mode 100644 index 0000000..eab4f9a --- /dev/null +++ b/services/worker/internal/storage/postgres/migrations/000002_backtest_analysis_results.up.sql @@ -0,0 +1,13 @@ +-- Add analysis result columns to backtest_results for summary metrics and equity +-- curve. Existing databases already at version 1 receive these columns here. +-- Backfill defaults keep the NOT NULL constraint valid for existing rows, then the +-- defaults are dropped so application code stays the single source of values. +ALTER TABLE backtest_results + ADD COLUMN IF NOT EXISTS total_return NUMERIC NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS trade_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS equity_curve JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE backtest_results + ALTER COLUMN total_return DROP DEFAULT, + ALTER COLUMN trade_count DROP DEFAULT, + ALTER COLUMN equity_curve DROP DEFAULT; diff --git a/services/worker/internal/storage/postgres/queries/queries.sql b/services/worker/internal/storage/postgres/queries/queries.sql index d44db1a..da9e2e0 100644 --- a/services/worker/internal/storage/postgres/queries/queries.sql +++ b/services/worker/internal/storage/postgres/queries/queries.sql @@ -52,18 +52,27 @@ SELECT id, strategy_id, market, timeframe, from_time, to_time, status, created_a FROM backtest_runs WHERE id = $1; +-- name: ListRuns :many +SELECT id, strategy_id, market, timeframe, from_time, to_time, status, created_at, updated_at +FROM backtest_runs +WHERE (@status::text = '' OR status = @status::text) +ORDER BY created_at DESC, id ASC; + -- 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) +INSERT INTO backtest_results (run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions, total_return, trade_count, equity_curve) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 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; + positions = EXCLUDED.positions, + total_return = EXCLUDED.total_return, + trade_count = EXCLUDED.trade_count, + equity_curve = EXCLUDED.equity_curve; -- name: GetResult :one -SELECT run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions +SELECT run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions, total_return, trade_count, equity_curve FROM backtest_results WHERE run_id = $1; diff --git a/services/worker/internal/storage/postgres/sqlc/models.go b/services/worker/internal/storage/postgres/sqlc/models.go index 564cb82..059aa91 100644 --- a/services/worker/internal/storage/postgres/sqlc/models.go +++ b/services/worker/internal/storage/postgres/sqlc/models.go @@ -16,6 +16,9 @@ type BacktestResult struct { EndingEquityAmount pgtype.Numeric Trades []byte Positions []byte + TotalReturn pgtype.Numeric + TradeCount int32 + EquityCurve []byte } type BacktestRun struct { diff --git a/services/worker/internal/storage/postgres/sqlc/queries.sql.go b/services/worker/internal/storage/postgres/sqlc/queries.sql.go index ec2b744..9b476fb 100644 --- a/services/worker/internal/storage/postgres/sqlc/queries.sql.go +++ b/services/worker/internal/storage/postgres/sqlc/queries.sql.go @@ -81,7 +81,7 @@ func (q *Queries) GetInstrument(ctx context.Context, id string) (Instrument, err } const getResult = `-- name: GetResult :one -SELECT run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions +SELECT run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions, total_return, trade_count, equity_curve FROM backtest_results WHERE run_id = $1 ` @@ -97,6 +97,9 @@ func (q *Queries) GetResult(ctx context.Context, runID string) (BacktestResult, &i.EndingEquityAmount, &i.Trades, &i.Positions, + &i.TotalReturn, + &i.TradeCount, + &i.EquityCurve, ) return i, err } @@ -157,6 +160,43 @@ func (q *Queries) ListInstruments(ctx context.Context) ([]Instrument, error) { return items, nil } +const listRuns = `-- name: ListRuns :many +SELECT id, strategy_id, market, timeframe, from_time, to_time, status, created_at, updated_at +FROM backtest_runs +WHERE ($1::text = '' OR status = $1::text) +ORDER BY created_at DESC, id ASC +` + +func (q *Queries) ListRuns(ctx context.Context, status string) ([]BacktestRun, error) { + rows, err := q.db.Query(ctx, listRuns, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BacktestRun + for rows.Next() { + var i BacktestRun + if err := rows.Scan( + &i.ID, + &i.StrategyID, + &i.Market, + &i.Timeframe, + &i.FromTime, + &i.ToTime, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertBar = `-- name: UpsertBar :exec INSERT INTO bars (instrument_id, timeframe, timestamp, open, high, low, close, volume) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -229,15 +269,18 @@ func (q *Queries) UpsertInstrument(ctx context.Context, arg UpsertInstrumentPara } 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) +INSERT INTO backtest_results (run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions, total_return, trade_count, equity_curve) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 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 + positions = EXCLUDED.positions, + total_return = EXCLUDED.total_return, + trade_count = EXCLUDED.trade_count, + equity_curve = EXCLUDED.equity_curve ` type UpsertResultParams struct { @@ -248,6 +291,9 @@ type UpsertResultParams struct { EndingEquityAmount pgtype.Numeric Trades []byte Positions []byte + TotalReturn pgtype.Numeric + TradeCount int32 + EquityCurve []byte } func (q *Queries) UpsertResult(ctx context.Context, arg UpsertResultParams) error { @@ -259,6 +305,9 @@ func (q *Queries) UpsertResult(ctx context.Context, arg UpsertResultParams) erro arg.EndingEquityAmount, arg.Trades, arg.Positions, + arg.TotalReturn, + arg.TradeCount, + arg.EquityCurve, ) return err } diff --git a/services/worker/internal/storage/postgres/store.go b/services/worker/internal/storage/postgres/store.go index c207437..8a8a941 100644 --- a/services/worker/internal/storage/postgres/store.go +++ b/services/worker/internal/storage/postgres/store.go @@ -20,6 +20,7 @@ var _ storage.InstrumentStore = (*Store)(nil) var _ storage.BarStore = (*Store)(nil) var _ storage.BacktestRunStore = (*Store)(nil) var _ storage.BacktestResultStore = (*Store)(nil) +var _ storage.BacktestAnalysisStore = (*Store)(nil) type Store struct { pool *pgxpool.Pool @@ -138,6 +139,50 @@ func (s *Store) GetRun(ctx context.Context, id backtest.RunID) (backtest.Run, er return run, nil } +func (s *Store) ListRuns(ctx context.Context, status backtest.RunStatus) ([]backtest.Run, error) { + rows, err := s.queries.ListRuns(ctx, string(status)) + if err != nil { + return nil, fmt.Errorf("failed to list runs: %w", err) + } + runs := make([]backtest.Run, len(rows)) + for i, row := range rows { + run, err := mapRowToRun(row) + if err != nil { + return nil, fmt.Errorf("failed to map run row at index %d: %w", i, err) + } + runs[i] = run + } + return runs, nil +} + +func (s *Store) GetRunDetail(ctx context.Context, id backtest.RunID) (storage.RunDetail, error) { + run, err := s.GetRun(ctx, id) + if err != nil { + return storage.RunDetail{}, err + } + + result, err := s.GetResult(ctx, id) + if err != nil { + if errors.Is(err, storage.ErrResultNotFound) { + return storage.RunDetail{Run: run, HasResult: false}, nil + } + return storage.RunDetail{}, err + } + return storage.RunDetail{Run: run, Result: result, HasResult: true}, nil +} + +func (s *Store) CompareResults(ctx context.Context, ids []backtest.RunID) ([]backtest.Result, error) { + results := make([]backtest.Result, 0, len(ids)) + for _, id := range ids { + result, err := s.GetResult(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get result for run %s: %w", id, err) + } + results = append(results, result) + } + return results, nil +} + func (s *Store) UpsertResult(ctx context.Context, result backtest.Result) error { params, err := mapResultToParams(result) if err != nil { diff --git a/services/worker/internal/storage/postgres/store_test.go b/services/worker/internal/storage/postgres/store_test.go new file mode 100644 index 0000000..9d4439e --- /dev/null +++ b/services/worker/internal/storage/postgres/store_test.go @@ -0,0 +1,200 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "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" +) + +// newTestStore connects to a local PostgreSQL instance using DATABASE_URL and runs +// the migrations inside a throwaway schema so the test is isolated from any existing +// data or prior migration state in the target database. It skips when no database is +// reachable so unit-only environments stay green; integration coverage runs when the +// local docker-compose Postgres (deployments/local) is up and DATABASE_URL is set. +func newTestStore(t *testing.T) *Store { + t.Helper() + + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + t.Skip("DATABASE_URL not set; skipping store integration test (start deployments/local Postgres and export DATABASE_URL)") + } + + ctx := context.Background() + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Skipf("DATABASE_URL set but parse failed (%v); skipping store integration test", err) + } + + // Identifier built only from digits/letters, safe to interpolate. + schema := fmt.Sprintf("test_analysis_%d", time.Now().UnixNano()) + // Every pooled connection isolates into the throwaway schema. + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + _, err := conn.Exec(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s; SET search_path TO %s", schema, schema)) + return err + } + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Skipf("DATABASE_URL set but pool create failed (%v); skipping store integration test", err) + } + + conn, err := pool.Acquire(ctx) + if err != nil { + pool.Close() + t.Skipf("DATABASE_URL set but connection unavailable (%v); skipping store integration test", err) + } + if err := RunMigrations(ctx, conn.Conn()); err != nil { + conn.Release() + pool.Close() + t.Fatalf("failed to run migrations: %v", err) + } + conn.Release() + + t.Cleanup(func() { + // Drop the throwaway schema (and its tables) regardless of search_path. + if c, err := pool.Acquire(ctx); err == nil { + _, _ = c.Exec(ctx, fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schema)) + c.Release() + } + pool.Close() + }) + + return NewStore(pool) +} + +func krw(amount string) market.Price { + return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: amount}} +} + +func TestStoreBacktestAnalysisReads(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + now := time.Now().UTC().Truncate(time.Microsecond) + // Unique IDs keep the test isolated from any other rows in a shared database. + suffix := now.Format("20060102T150405.000000000") + runOlderID := backtest.RunID("test-analysis-older-" + suffix) + runNewerID := backtest.RunID("test-analysis-newer-" + suffix) + runNoResultID := backtest.RunID("test-analysis-noresult-" + suffix) + + spec := backtest.RunSpec{ + StrategyID: backtest.StrategyID("strat-analysis"), + Market: market.MarketKR, + Timeframe: market.TimeframeDaily, + From: now.Add(-72 * time.Hour), + To: now, + } + + runOlder := backtest.Run{ID: runOlderID, Spec: spec, Status: backtest.RunStatusSucceeded, CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-2 * time.Hour)} + runNewer := backtest.Run{ID: runNewerID, Spec: spec, Status: backtest.RunStatusSucceeded, CreatedAt: now.Add(-1 * time.Hour), UpdatedAt: now.Add(-1 * time.Hour)} + runNoResult := backtest.Run{ID: runNoResultID, Spec: spec, Status: backtest.RunStatusRunning, CreatedAt: now, UpdatedAt: now} + + for _, run := range []backtest.Run{runOlder, runNewer, runNoResult} { + if err := store.UpsertRun(ctx, run); err != nil { + t.Fatalf("failed to seed run %s: %v", run.ID, err) + } + } + + resultOlder := backtest.Result{ + RunID: runOlderID, + StartingCash: krw("10000000"), + EndingEquity: krw("10000300"), + Summary: backtest.SummaryMetrics{StartingCash: krw("10000000"), EndingEquity: krw("10000300"), TotalReturn: market.Decimal{Value: "0.00003"}, TradeCount: 2}, + EquityCurve: []backtest.EquityPoint{{Timestamp: now.Add(-2 * time.Hour), Equity: krw("10000300")}}, + } + resultNewer := backtest.Result{ + RunID: runNewerID, + StartingCash: krw("10000000"), + EndingEquity: krw("9999000"), + Summary: backtest.SummaryMetrics{StartingCash: krw("10000000"), EndingEquity: krw("9999000"), TotalReturn: market.Decimal{Value: "-0.0001"}, TradeCount: 1}, + EquityCurve: []backtest.EquityPoint{{Timestamp: now.Add(-1 * time.Hour), Equity: krw("9999000")}}, + } + for _, res := range []backtest.Result{resultOlder, resultNewer} { + if err := store.UpsertResult(ctx, res); err != nil { + t.Fatalf("failed to seed result %s: %v", res.RunID, err) + } + } + + // ListRuns returns newest CreatedAt first: runNewer must appear before runOlder. + runs, err := store.ListRuns(ctx, "") + if err != nil { + t.Fatalf("ListRuns returned error: %v", err) + } + idxNewer, idxOlder := indexOfRun(runs, runNewerID), indexOfRun(runs, runOlderID) + if idxNewer < 0 || idxOlder < 0 { + t.Fatalf("ListRuns missing seeded runs: newer=%d older=%d", idxNewer, idxOlder) + } + if idxNewer > idxOlder { + t.Errorf("ListRuns ordering: newer run at %d should precede older run at %d", idxNewer, idxOlder) + } + + // ListRuns filters by status: the running run appears, succeeded runs do not. + running, err := store.ListRuns(ctx, backtest.RunStatusRunning) + if err != nil { + t.Fatalf("ListRuns(running) returned error: %v", err) + } + if indexOfRun(running, runNoResultID) < 0 { + t.Errorf("ListRuns(running) should include the running run") + } + if indexOfRun(running, runNewerID) >= 0 { + t.Errorf("ListRuns(running) should not include a succeeded run") + } + + // GetRunDetail for a run without a result reports HasResult=false and no error. + detail, err := store.GetRunDetail(ctx, runNoResultID) + if err != nil { + t.Fatalf("GetRunDetail(no-result) returned error: %v", err) + } + if detail.HasResult { + t.Errorf("GetRunDetail(no-result) HasResult should be false") + } + if detail.Run.ID != runNoResultID { + t.Errorf("GetRunDetail(no-result) run id mismatch: got %s", detail.Run.ID) + } + + // GetRunDetail for a run with a result returns it. + detailWith, err := store.GetRunDetail(ctx, runNewerID) + if err != nil { + t.Fatalf("GetRunDetail(with-result) returned error: %v", err) + } + if !detailWith.HasResult || detailWith.Result.Summary.TradeCount != 1 { + t.Errorf("GetRunDetail(with-result) unexpected: %+v", detailWith) + } + + // CompareResults preserves the caller's input order. + compared, err := store.CompareResults(ctx, []backtest.RunID{runNewerID, runOlderID}) + if err != nil { + t.Fatalf("CompareResults returned error: %v", err) + } + if len(compared) != 2 { + t.Fatalf("CompareResults length: got %d, want 2", len(compared)) + } + if compared[0].RunID != runNewerID || compared[1].RunID != runOlderID { + t.Errorf("CompareResults order not preserved: got %s, %s", compared[0].RunID, compared[1].RunID) + } + + // CompareResults propagates a missing result as ErrResultNotFound. + _, err = store.CompareResults(ctx, []backtest.RunID{runNewerID, runNoResultID}) + if !errors.Is(err, storage.ErrResultNotFound) { + t.Errorf("CompareResults missing result: expected ErrResultNotFound, got %v", err) + } +} + +func indexOfRun(runs []backtest.Run, id backtest.RunID) int { + for i, run := range runs { + if run.ID == id { + return i + } + } + return -1 +}