feat: backtest analysis surface implementation

- Add backtest analysis results migration (000002)
- Update backtest proto definitions and generated code
- Extend backtest domain types and engine
- Add parser map for backtest analysis
- Update storage layer with backtest analysis queries and keys
- Add client contract tests and generated code
- Add m-backtest-analysis-surface task documentation
This commit is contained in:
toki 2026-05-30 14:43:40 +09:00
parent a5710b20ff
commit 9efc0a68dc
26 changed files with 2366 additions and 152 deletions

View file

@ -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/...`로 확인했다.
- 확인 필요: 없음

View file

@ -0,0 +1,133 @@
<!-- task=m-backtest-analysis-surface/03+01,02_api_client_boundary plan=0 tag=BAS-BOUNDARY -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md``CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- 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.

View file

@ -0,0 +1,229 @@
<!-- task=m-backtest-analysis-surface/03+01,02_api_client_boundary plan=0 tag=BAS-BOUNDARY -->
# 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<HelloResponse> hello({Duration timeout = const Duration(seconds: 2)}) {
33 return sendRequest<HelloRequest, HelloResponse>(
```
After:
```dart
Future<ListBacktestRunsResponse> listBacktestRuns(
ListBacktestRunsRequest request, {
Duration timeout = const Duration(seconds: 2),
}) {
return sendRequest<ListBacktestRunsRequest, ListBacktestRunsResponse>(
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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -19,5 +19,11 @@ Map<String, GeneratedMessage Function(List<int>)> 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,
};
}

View file

@ -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<BacktestSummaryMetrics>(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<BacktestEquityPoint>(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<BacktestTrade>? trades,
$core.Iterable<BacktestPosition>? positions,
BacktestSummaryMetrics? summary,
$core.Iterable<BacktestEquityPoint>? 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<BacktestPosition>(5, _omitFieldNames ? '' : 'positions',
subBuilder: BacktestPosition.create)
..aOM<BacktestSummaryMetrics>(6, _omitFieldNames ? '' : 'summary',
subBuilder: BacktestSummaryMetrics.create)
..pPM<BacktestEquityPoint>(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<BacktestPosition> 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<BacktestEquityPoint> 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<BacktestRunStatus>(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<ListBacktestRunsRequest>(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<BacktestRun>? 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<BacktestRun>(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<ListBacktestRunsResponse>(create);
static ListBacktestRunsResponse? _defaultInstance;
@$pb.TagNumber(1)
$pb.PbList<BacktestRun> 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<GetBacktestRunDetailRequest>(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<BacktestRun>(1, _omitFieldNames ? '' : 'run',
subBuilder: BacktestRun.create)
..aOM<BacktestResult>(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<GetBacktestRunDetailResponse>(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<CompareBacktestRunsRequest>(create);
static CompareBacktestRunsRequest? _defaultInstance;
@$pb.TagNumber(1)
$pb.PbList<$core.String> get runIds => $_getList(0);
}
class CompareBacktestRunsResponse extends $pb.GeneratedMessage {
factory CompareBacktestRunsResponse({
$core.Iterable<BacktestResult>? 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<BacktestResult>(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<CompareBacktestRunsResponse>(create);
static CompareBacktestRunsResponse? _defaultInstance;
@$pb.TagNumber(1)
$pb.PbList<BacktestResult> get results => $_getList(0);
}
const $core.bool _omitFieldNames =
$core.bool.fromEnvironment('protobuf.omit_field_names');
const $core.bool _omitMessageNames =

View file

@ -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==');

View file

@ -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;

View file

@ -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,
},

View file

@ -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;
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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{} }),
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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)
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -16,6 +16,9 @@ type BacktestResult struct {
EndingEquityAmount pgtype.Numeric
Trades []byte
Positions []byte
TotalReturn pgtype.Numeric
TradeCount int32
EquityCurve []byte
}
type BacktestRun struct {

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}