feat: paper trading command workflow completed (G08, G09)
- Archive completed subtasks (02+01_risk_command, 03+02_order_lifecycle) - Add paper_order_lifecycle test data and expected output - Update paper trading proto and regenerate code (Dart, Go) - Fix order lifecycle handling in CLI operator, API socket, worker socket - Update parser maps across CLI, API, and worker services - Update backtest and paper trading tests
This commit is contained in:
parent
1ad517beec
commit
daa29d6807
37 changed files with 5120 additions and 275 deletions
|
|
@ -38,8 +38,8 @@ paper trading을 화면 없이 반복 검증하고, 나중에 UI로 올릴 운
|
|||
|
||||
- [x] [paper-status-command] paper account, portfolio, position 상태를 command로 조회할 수 있다. 검증: status command 또는 scenario가 account_id, cash, position_count, risk key를 출력한다.
|
||||
- [ ] [paper-order-command] virtual order submit/cancel/fill simulation을 command로 실행할 수 있다. 검증: order lifecycle scenario가 order id, status transition, fill summary를 출력한다.
|
||||
- [ ] [paper-risk-command] 최소 risk guard 결과를 command workflow에서 확인할 수 있다. 검증: 허용/차단 case가 typed status/error key로 구분된다.
|
||||
- [ ] [paper-loop-smoke] paper execution loop를 fixture 또는 fake market data로 smoke 실행할 수 있다. 검증: loop command가 run id, terminal status, position/equity summary를 출력한다.
|
||||
- [x] [paper-risk-command] 최소 risk guard 결과를 command workflow에서 확인할 수 있다. 검증: 허용/차단 case가 typed status/error key로 구분된다.
|
||||
- [x] [paper-loop-smoke] paper execution loop를 fixture 또는 fake market data로 smoke 실행할 수 있다. 검증: loop command가 run id, terminal status, position/equity summary를 출력한다.
|
||||
- [ ] [paper-ui-handoff] paper trading에서 화면에 올릴 후보와 아직 command로만 둘 항목을 분리한다. 검증: handoff 문서가 command, expected output key, 반복 운영 여부, UI defer 사유를 기록한다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
|
@ -65,14 +65,16 @@ paper trading을 화면 없이 반복 검증하고, 나중에 UI로 올릴 운
|
|||
- 관련 경로: `apps/cli/`, `apps/cli/testdata/operator/`, `services/worker/`, `services/api/`, `packages/domain/`
|
||||
- 표준선(선택): paper trading은 실거래 전 검증 경계이며, 먼저 command workflow로 반복 운영성과 상태 key를 확인한다.
|
||||
- 표준선(선택): UI는 command workflow에서 반복되는 status/action/evidence key가 정리된 뒤에만 후보로 올린다.
|
||||
- 현재 작업: 첫 scenario는 status 조회 우선으로 정해졌고, 구현 계획을 만들 수 있다.
|
||||
- 현재 작업: order lifecycle과 UI handoff subtask가 active review 상태다.
|
||||
- 직접 처리 완료:
|
||||
- [x] `paper-status-command`: status output에 `account_id`, `cash`, `position_count`, `risk` key를 고정했고 2026-06-05 local `bin/test`가 통과했다.
|
||||
- [x] `paper-loop-smoke`: `agent-task/archive/2026/06/m-paper-trading-command-workflow/01_loop_smoke/complete.log` 기준 PASS. 검증: `go test -count=1 ./apps/cli/internal/operator`, `PATH="/tmp/protoc293/bin:$PATH" bin/test`.
|
||||
- [x] `paper-risk-command`: `agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/complete.log` 기준 PASS. 검증: `PATH="/tmp/protoc293/bin:$PATH" bin/contracts-check`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator`, `PATH="/tmp/protoc293/bin:$PATH" bin/test`.
|
||||
- 큰 작업 plan:
|
||||
- `agent-task/m-paper-trading-command-workflow/01_loop_smoke/PLAN-cloud-G07.md`
|
||||
- `agent-task/m-paper-trading-command-workflow/02+01_risk_command/PLAN-cloud-G08.md`
|
||||
- `agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/PLAN-cloud-G09.md`
|
||||
- `agent-task/m-paper-trading-command-workflow/04+01,02,03_ui_handoff/PLAN-local-G04.md`
|
||||
- 완료: `agent-task/archive/2026/06/m-paper-trading-command-workflow/01_loop_smoke/complete.log`
|
||||
- 완료: `agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/complete.log`
|
||||
- 활성: `agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/PLAN-cloud-G09.md`
|
||||
- 활성: `agent-task/m-paper-trading-command-workflow/04+01,02,03_ui_handoff/PLAN-local-G04.md`
|
||||
- 진행 순서:
|
||||
1. status 조회 scenario로 account_id, cash, position_count, risk key를 고정한다.
|
||||
2. paper execution loop smoke로 run id, terminal status, position/equity summary를 확인한다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
<!-- task=m-paper-trading-command-workflow/02+01_risk_command plan=0 tag=PAPER_RISK -->
|
||||
|
||||
# Code Review Reference - PAPER_RISK
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> Complete implementation-owned sections, paste actual verification output, and stop with active files in place for review.
|
||||
> If user-only input is required, fill `사용자 리뷰 요청` and do not ask the user directly.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-05
|
||||
task=m-paper-trading-command-workflow/02+01_risk_command, plan=0, tag=PAPER_RISK
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Task ids:
|
||||
- `paper-risk-command`: risk guard command workflow
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** PASS/WARN/FAIL 판정, log archive, `complete.log`, task archive는 리뷰 에이전트 전용이다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [PAPER_RISK-1] risk state contract and worker mapping | [x] |
|
||||
| [PAPER_RISK-2] CLI risk output keys | [x] |
|
||||
| [PAPER_RISK-3] risk fixture/tests | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] [PAPER_RISK-1] `Snapshot.Rejected`를 service state와 protobuf state로 additive하게 노출한다.
|
||||
- [x] [PAPER_RISK-2] worker/API/CLI mapping이 risk summary를 stable key로 출력하게 한다.
|
||||
- [x] [PAPER_RISK-3] risk allow/block fixture 또는 fake API test가 typed status/error key를 검증하게 한다. 검증: `bin/contracts-check`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator` 통과.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
|
||||
- [x] PASS/WARN/FAIL 판정을 append한다.
|
||||
- [x] active files를 `.log`로 아카이브하고 PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
- [x] PASS이면 roadmap completion metadata를 보고한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 계획은 `risk=clear|blocked`, `risk_rejected_count`, `risk_reason` 키만 명시했으나, 기존 paper output은 `risk=not_evaluated` placeholder를 emit하고 있었다. additive로 추가하는 대신 placeholder를 실제 derive 값(`clear`/`blocked`)으로 **대체**했다. 이유: worker가 항상 rejection summary를 채우므로 `not_evaluated`를 유지하면 의미 없는 third state가 남는다. 이에 따라 기존 테스트 단언과 expected fixture의 `not_evaluated`를 `clear`로 갱신했다.
|
||||
- proto는 단일 `repeated PaperRiskRejection risk_rejections = 7` field만 additive로 추가했다. 별도 `risk_status` enum field는 두지 않았다 — clear vs blocked는 list 길이로 결정 가능하고, 이렇게 두면 worker가 단일 source(`Snapshot.Rejected`)만 매핑하면 되어 drift 가능성이 줄어든다.
|
||||
- API(`services/api`)는 순수 worker pass-through라 코드 변경이 필요 없었다(계획 수정 파일 요약에도 API 파일은 없음). `go test ./services/api/...`로 회귀만 확인했다.
|
||||
- 계획 요청대로 `TestRunGetPaperTradingStateOutputsRiskFields`(blocked)를 추가했고, 추가로 `TestRunStartPaperTradingClearRiskOmitsRejectionFields`(clear)를 두어 clear/blocked 양쪽 case를 모두 고정했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **risk summary는 broker 동작을 fake하지 않는다.** blocked는 typed error가 아니라 paper state의 `risk_rejections`(engine이 이미 추적하던 `Snapshot.Rejected`)로만 표현한다. 진짜 invalid request(account_id 누락 등)는 기존 typed error 경로를 그대로 유지한다.
|
||||
- **rejection instrument_id는 `RejectedOrder.Instrument`에서 매핑한다.** engine은 risk/applyfill reject에는 `order.InstrumentID`를, empty-order-id strategy bug case에는 `bar.InstrumentID`를 `.Instrument`에 넣으므로 항상 non-empty 값이 보장된다.
|
||||
- **clear run output 최소화.** `risk_rejected_count`/`risk_reason`은 blocked일 때만 emit하여 clear run의 evidence 라인이 안정적으로 유지된다(`risk=clear`만 출력).
|
||||
- **codegen은 protoc 29.3로 수행.** 생성 header가 `protoc v5.29.3`(libprotoc 29.3)이며, 시스템 protoc 3.21.12로 생성하면 header drift로 `bin/contracts-check`/`bin/test`가 실패한다(선행 `01_loop_smoke` G07에서 동일 이슈 확인됨). `PATH="/tmp/protoc293/bin:$PATH" bin/contracts-gen`으로 Go/Dart 생성물을 재생성했다. 후속 리뷰/CI도 동일 protoc 버전을 사용해야 한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- protobuf changes must be additive and generated outputs must match.
|
||||
- risk summary must distinguish clear vs blocked without faking broker behavior.
|
||||
- API must stay thin and worker-owned runtime must remain worker-owned.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### PAPER_RISK-1 중간 검증
|
||||
```text
|
||||
$ PATH="/tmp/protoc293/bin:$PATH" bin/contracts-gen
|
||||
(no output; Go + Dart regenerated)
|
||||
$ PATH="/tmp/protoc293/bin:$PATH" bin/contracts-check
|
||||
(exit=0; no drift)
|
||||
```
|
||||
|
||||
### PAPER_RISK-1/2/3 focused 검증
|
||||
```text
|
||||
$ go test ./services/worker/internal/socket
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/socket
|
||||
$ go test ./services/api/internal/socket
|
||||
ok git.toki-labs.com/toki/alt/services/api/internal/socket
|
||||
$ go test ./apps/cli/internal/operator
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.161s
|
||||
```
|
||||
|
||||
worker 전체:
|
||||
```text
|
||||
$ go test ./services/worker/...
|
||||
ok ... all worker packages (papertrading, socket 포함)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ PATH="/tmp/protoc293/bin:$PATH" bin/test
|
||||
... (Go modules) ok ...
|
||||
00:02 +60: All tests passed! # Flutter
|
||||
exit=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계: PASS 처리한다. active `CODE_REVIEW-cloud-G08.md`와 `PLAN-cloud-G08.md`를 `.log`로 아카이브하고, `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Complete - m-paper-trading-command-workflow/02+01_risk_command
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-06
|
||||
|
||||
## 요약
|
||||
|
||||
paper risk guard summary contract, worker mapping, and CLI output keys completed in 1 review loop; final verdict PASS.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | PASS | risk rejection state is exposed additively through protobuf, mapped from worker state, and rendered as stable CLI risk keys; focused and full verification passed. |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- Added additive `PaperRiskRejection` / `risk_rejections` contract fields and regenerated Go/Dart protobuf outputs.
|
||||
- Preserved engine `Snapshot.Rejected` in paper service state and mapped rejected orders into worker socket protobuf state.
|
||||
- Replaced CLI `risk=not_evaluated` with derived `risk=clear|blocked`, adding `risk_rejected_count` and `risk_reason` for blocked runs only.
|
||||
- Added worker socket and CLI fake API tests for rejection mapping, blocked risk output, and clear output without rejection detail keys.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `PATH="/tmp/protoc293/bin:$PATH" bin/contracts-check` - PASS; exit 0 with no drift output.
|
||||
- `go test ./packages/contracts/gen/go/...` - PASS; `? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]`.
|
||||
- `go test ./services/worker/...` - PASS; all worker packages passed or reported `[no test files]`.
|
||||
- `go test ./services/api/...` - PASS; all API packages passed or reported `[no test files]`.
|
||||
- `go test ./apps/cli/internal/operator` - PASS; `ok git.toki-labs.com/toki/alt/apps/cli/internal/operator`.
|
||||
- `PATH="/tmp/protoc293/bin:$PATH" bin/test` - PASS; all Go module checks passed and Flutter ended with `00:02 +60: All tests passed!`.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Completed task ids:
|
||||
- `paper-risk-command`: PASS; evidence=`plan_cloud_G08_0.log`, `code_review_cloud_G08_0.log`; verification=`PATH="/tmp/protoc293/bin:$PATH" bin/contracts-check`, `go test ./packages/contracts/gen/go/...`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator`, `PATH="/tmp/protoc293/bin:$PATH" bin/test`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -73,10 +73,10 @@ rename/remove 없음. `rg --sort path "RejectedOrder|RiskDecision|PaperTradingSt
|
|||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [PAPER_RISK-1] `Snapshot.Rejected`를 service state와 protobuf state로 additive하게 노출한다.
|
||||
- [ ] [PAPER_RISK-2] worker/API/CLI mapping이 risk summary를 stable key로 출력하게 한다.
|
||||
- [ ] [PAPER_RISK-3] risk allow/block fixture 또는 fake API test가 typed status/error key를 검증하게 한다. 검증: `bin/contracts-check`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator` 통과.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] [PAPER_RISK-1] `Snapshot.Rejected`를 service state와 protobuf state로 additive하게 노출한다.
|
||||
- [x] [PAPER_RISK-2] worker/API/CLI mapping이 risk summary를 stable key로 출력하게 한다.
|
||||
- [x] [PAPER_RISK-3] risk allow/block fixture 또는 fake API test가 typed status/error key를 검증하게 한다. 검증: `bin/contracts-check`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator` 통과.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## PAPER_RISK-1
|
||||
|
||||
|
|
@ -90,10 +90,10 @@ rename/remove 없음. `rg --sort path "RejectedOrder|RiskDecision|PaperTradingSt
|
|||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `packages/contracts/proto/alt/v1/paper_trading.proto`
|
||||
- [ ] generated Go/Dart contract outputs via `bin/contracts-gen`
|
||||
- [ ] `services/worker/internal/papertrading/service.go`
|
||||
- [ ] `services/worker/internal/socket/paper_mapping.go`
|
||||
- [x] `packages/contracts/proto/alt/v1/paper_trading.proto`
|
||||
- [x] generated Go/Dart contract outputs via `bin/contracts-gen`
|
||||
- [x] `services/worker/internal/papertrading/service.go`
|
||||
- [x] `services/worker/internal/socket/paper_mapping.go`
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
|
|
@ -115,9 +115,9 @@ proto risk/rejected summary를 읽어 `risk=clear|blocked`, `risk_rejected_count
|
|||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/cli/internal/operator/output.go`
|
||||
- [ ] `apps/cli/internal/operator/runner.go`
|
||||
- [ ] `apps/cli/internal/operator/runner_paper_test.go`
|
||||
- [x] `apps/cli/internal/operator/output.go`
|
||||
- [x] `apps/cli/internal/operator/runner.go`
|
||||
- [x] `apps/cli/internal/operator/runner_paper_test.go`
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
|
|
@ -139,8 +139,8 @@ paper scenario fixture 또는 focused fake API test에 clear and blocked cases
|
|||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/cli/testdata/operator/*`
|
||||
- [ ] `apps/cli/internal/operator/runner_paper_test.go`
|
||||
- [x] `apps/cli/testdata/operator/*`
|
||||
- [x] `apps/cli/internal/operator/runner_paper_test.go`
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<!-- task=m-paper-trading-command-workflow/03+02_order_lifecycle plan=0 tag=PAPER_ORDER -->
|
||||
|
||||
# Code Review Reference - PAPER_ORDER
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> Complete implementation-owned sections, paste actual verification output, and stop with active files in place for review.
|
||||
> Do not ask the user directly; use `사용자 리뷰 요청` for user-only blockers.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-05
|
||||
task=m-paper-trading-command-workflow/03+02_order_lifecycle, plan=0, tag=PAPER_ORDER
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Task ids:
|
||||
- `paper-order-command`: virtual order lifecycle command
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** PASS/WARN/FAIL 판정과 archive는 리뷰 에이전트 전용이다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [PAPER_ORDER-1] paper order contract | [x] |
|
||||
| [PAPER_ORDER-2] worker order lifecycle state | [x] |
|
||||
| [PAPER_ORDER-3] API/CLI lifecycle scenario | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] [PAPER_ORDER-1] protobuf contract에 paper order command/query messages를 additive하게 추가하고 parser maps/client methods를 갱신한다.
|
||||
- [x] [PAPER_ORDER-2] worker paper service가 virtual order submit/cancel/fill simulation state를 in-memory로 관리하고 typed status를 반환한다.
|
||||
- [x] [PAPER_ORDER-3] API pass-through와 CLI scenario action이 order id, status transition, fill summary를 출력한다. 검증: `bin/contracts-check`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator`, `bin/test` 통과.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
|
||||
- [x] PASS/WARN/FAIL 판정을 append한다.
|
||||
- [x] active files를 `.log`로 아카이브하고 PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
- [ ] PASS이면 roadmap completion metadata를 보고한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 계획은 worker registry 키를 `account/order id`로만 명시했으나, 구현은 계정별 `orderBook`(seed run state + live portfolio + manual fills + orders map)으로 모델링했다. 이유: fill simulation이 `CheckRisk`/`FillOrderOnDailyBar`/`ApplyFill` 도메인 헬퍼를 그대로 재사용하려면 계정 portfolio가 필요한데, 이를 별도 starting cash 없이 얻으려면 이미 시작된 paper run의 terminal state를 seed로 삼는 것이 가장 자연스럽다. order book은 처음 submit 시 `StartPaperTrading`이 기록한 state에서 lazy seed된다(미시작 계정은 `ErrAccountNotFound`).
|
||||
- 계획에 없던 동작으로 `GetPaperTradingState`가 order book이 있으면 live snapshot을 반환하도록 했다. 이유: manual fill 후 계정 view가 stale run terminal state가 아닌 실제 cash/position을 반영해야 운영자가 lifecycle 결과를 동일 query로 확인할 수 있다. order book이 없으면 기존 동작(run terminal state)을 그대로 유지하므로 `paper-status-command`/`paper-loop-smoke` fixture는 영향받지 않는다.
|
||||
- `FillPaperOrderResponse`에 `state`(PaperTradingState) 필드를 추가했다. 계획의 "fill summary" 요구는 `PaperOrder.fill`(BacktestTrade)로 충족하지만, fill이 portfolio에 미치는 cash/position 효과를 추가 query 없이 노출하기 위해 응답에 계정 snapshot을 함께 실었다.
|
||||
- 범위 축소(submit/cancel/fill 중 일부만)는 하지 않았다. 세 동작 모두 구현했고 plan 범위 내에서 안전하게 진행 가능했으므로 `사용자 리뷰 요청`은 `없음`이다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **paper-only, 추가(additive) 계약**: `paper_trading.proto`에 `PaperOrder`, `Submit/Cancel/Fill` request/response를 추가했다. side/type/status는 기존 `PaperRiskRejection.side`와 동일하게 string으로 두어 새 enum 도입 없이 additive를 유지했다. field 번호 재사용/삭제 없음. 실거래 broker 동작은 도입하지 않았다.
|
||||
- **request 오류 vs business rejection 구분**: 계정/주문 부재, 비-pending 전이, market 주문의 fill_price 누락은 typed error(`not_found`/`invalid_request`)로 반환한다. 반면 risk gate 차단과 cash/position 부족은 error가 아니라 주문을 `status=rejected`(reason 포함)로 전이시키고 성공 응답으로 돌려준다. lifecycle command의 목적이 status transition을 보여주는 것이므로 rejection은 정상 흐름의 한 상태로 표현했다.
|
||||
- **fill 가격 결정**: market 주문은 운영자가 준 `fill_price`로, limit 주문은 자신의 limit price로 단일-price 합성 bar를 만들어 기존 `FillOrderOnDailyBar`로 체결한다. 운영자가 "fill"을 호출한 것 자체가 시장이 그 가격에 도달했다는 가정이므로, command 경로에서는 limit crossing 재판정 없이 limit price로 체결한다.
|
||||
- **결정성**: order id는 계정별 증가 counter로 `paper-order-<account>-<n>` 형식이며 주입 가능한 clock(`now`)을 사용해 테스트가 결정적이다.
|
||||
- **rail 경계 유지**: worker가 runtime state와 proto<->domain 매핑을 소유하고, API는 shape 검증만 한 뒤 worker로 forward하는 thin pass-through를 유지했다. CLI는 API 내부 패키지를 import하지 않고 자체 ParserMap으로 같은 rail을 사용한다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- No real broker behavior is introduced.
|
||||
- Order state transitions are deterministic and tested.
|
||||
- API remains pass-through; worker owns runtime state.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
검증은 현재 local checkout에서 실행했다. 커밋된 generated 코드는 protoc v5.29.3로 만들어졌으므로(시스템 protoc은 3.21.12), contract 재생성/검사에는 `PATH="/tmp/protoc293/bin:$PATH"`로 protoc v5.29.3을 앞세웠다(`protoc-gen-go` v1.36.11, dart plugin은 flutter dart 사용). DB/Redis가 필요한 import 기반 end-to-end operations smoke(`paper_order_lifecycle.yaml`의 실 API run)는 remote runner 대상이며 local에서는 실행하지 않았다. fixture는 dry-run validation과 expected JSONL key 검사(`TestPaperOrderLifecycleFixtureIsValid`)로 검증했고, lifecycle 흐름은 in-process fake API 라운드트립(`TestRunPaperOrderLifecycle`)으로 검증했다.
|
||||
|
||||
### PAPER_ORDER-1 중간 검증
|
||||
```text
|
||||
$ PATH="/tmp/protoc293/bin:$PATH" bin/contracts-check
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
드리프트 출력 없음 = generated Go/Dart가 최신. 새 메시지 5종 확인:
|
||||
```text
|
||||
$ grep -c "type PaperOrder struct\|type SubmitPaperOrderRequest struct\|type CancelPaperOrderRequest struct\|type FillPaperOrderRequest struct\|type FillPaperOrderResponse struct" \
|
||||
packages/contracts/gen/go/alt/v1/paper_trading.pb.go
|
||||
5
|
||||
```
|
||||
|
||||
### PAPER_ORDER-2 중간 검증
|
||||
```text
|
||||
$ go test ./services/worker/...
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.072s
|
||||
...(전 패키지 ok / no test files)
|
||||
```
|
||||
신규 order lifecycle service 테스트:
|
||||
```text
|
||||
--- PASS: TestServiceSubmitOrderRecordsPending (0.00s)
|
||||
--- PASS: TestServiceSubmitOrderRequiresStartedAccount (0.00s)
|
||||
--- PASS: TestServiceSubmitOrderRejectsInvalidIntent (0.00s)
|
||||
--- PASS: TestServiceFillMarketOrderUpdatesPortfolio (0.00s)
|
||||
--- PASS: TestServiceFillLimitOrderUsesLimitPrice (0.00s)
|
||||
--- PASS: TestServiceFillMarketOrderRequiresFillPrice (0.00s)
|
||||
--- PASS: TestServiceFillRejectedByRisk (0.00s)
|
||||
--- PASS: TestServiceFillRejectedByInsufficientCash (0.00s)
|
||||
--- PASS: TestServiceCancelTransitionsPendingOrder (0.00s)
|
||||
--- PASS: TestServiceCancelMissingOrder (0.00s)
|
||||
--- PASS: TestServiceGetStateReflectsOrderBook (0.00s)
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
|
||||
```
|
||||
|
||||
### PAPER_ORDER-3 중간 검증
|
||||
```text
|
||||
$ go test ./services/api/...
|
||||
ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.021s
|
||||
ok git.toki-labs.com/toki/alt/services/api/internal/workerclient 0.413s
|
||||
...(전 패키지 ok / no test files)
|
||||
|
||||
$ go test ./apps/cli/internal/operator
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.170s
|
||||
```
|
||||
|
||||
### go vet (touched packages)
|
||||
```text
|
||||
$ go vet ./services/worker/internal/papertrading/ ./services/worker/internal/socket/ \
|
||||
./services/api/internal/socket/ ./services/api/internal/workerclient/ ./apps/cli/internal/operator/
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ PATH="/tmp/protoc293/bin:$PATH" bin/test
|
||||
...(contracts-check + 5 Go 모듈 go test ./... + flutter test)
|
||||
00:02 +60: All tests passed!
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Fail
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- Required - `services/worker/internal/papertrading/service.go:127`: `StartPaperTrading` replaces `states[accountID]` but leaves an existing `books[accountID]` intact. After any submit/fill creates an order book, restarting the same paper account still makes `GetPaperTradingState` return the old order-book snapshot and makes subsequent orders continue the old sequence/portfolio. Fix by clearing the account's order book when a new paper run is recorded, and add a regression test that starts, fills or submits, starts the same account again, then verifies state/order ids are seeded from the new run.
|
||||
- Required - `services/worker/internal/papertrading/service.go:450`, `services/worker/internal/socket/paper_mapping.go:49`: order lifecycle decimal validation only checks that quantity/price strings are non-empty, so non-parsable or negative `quantity`, `limit_price`, and `fill_price` values can enter the lifecycle before the order is recorded or filled. That lets malformed orders enter `pending` and later become `rejected` or even an internal fill error instead of a typed `invalid_request`. Fix by validating decimal syntax and sign before storing/filling orders, and cover zero/negative/non-numeric quantity plus malformed limit/fill prices in worker/service and socket or CLI tests.
|
||||
- 다음 단계: FAIL 후속 `PLAN-*-G??.md`와 `CODE_REVIEW-*-G??.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
<!-- task=m-paper-trading-command-workflow/03+02_order_lifecycle plan=1 tag=REVIEW_PAPER_ORDER -->
|
||||
|
||||
# Code Review Reference - REVIEW_PAPER_ORDER
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
|
||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
|
||||
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-06
|
||||
task=m-paper-trading-command-workflow/03+02_order_lifecycle, plan=1, tag=REVIEW_PAPER_ORDER
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Task ids:
|
||||
- `paper-order-command`: virtual order lifecycle command
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_PAPER_ORDER-1] restart resets order book | [x] |
|
||||
| [REVIEW_PAPER_ORDER-2] lifecycle decimal validation | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] [REVIEW_PAPER_ORDER-1] `StartPaperTrading`이 같은 account의 기존 order book을 새 run state와 함께 초기화하고, restart regression test를 추가한다.
|
||||
- [x] [REVIEW_PAPER_ORDER-2] order lifecycle quantity/price decimal을 저장/체결 전에 typed invalid request로 검증하고, malformed/zero/negative input regression tests를 추가한다. 검증: `go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket`, `go test -count=1 ./apps/cli/internal/operator`, `PATH="/tmp/protoc293/bin:$PATH" bin/test` 통과.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
|
||||
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/`를 `agent-task/archive/YYYY/MM/m-paper-trading-command-workflow/03+02_order_lifecycle/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-paper-trading-command-workflow`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-paper-trading-command-workflow/`를 제거하거나, 남은 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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 계획대로 구현했고 임의 명령 대체는 없다. 계획의 수정 파일 목록에 더해 worker socket fill 경계 검증을 `services/worker/internal/socket/paper.go`의 `handleFillPaperOrder`에도 추가했다. 이유: fill_price 변환은 mapping 함수가 아닌 fill 핸들러에서 일어나므로, malformed/negative fill price를 fake service에 도달하기 전에 boundary에서 `invalid_request`로 거절하려면 핸들러에서 검증해야 한다. 또한 decimal 검증을 한 곳에만 두지 않고 `papertrading.ValidateOrderDecimal`로 공유해 socket boundary(빠른 거절)와 service(direct caller 방어)에서 동일 규칙을 쓴다.
|
||||
- CLI invalid 입력 evidence는 plan의 선택지 중 "CLI operator test 추가"를 택했다(`TestRunSubmitPaperOrderTypedError`). 단, 실제 decimal 검증은 worker가 소유하며 CLI는 thin forward이므로, CLI 테스트는 worker가 돌려준 typed `invalid_request`를 CLI가 transport 실패가 아닌 typed error로 표면화하는지만 검증한다. 입력 자체의 유효성 판정 회귀는 worker service/socket 테스트가 담당한다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **REVIEW_PAPER_ORDER-1 (restart)**: `StartPaperTrading`이 새 run state를 기록하는 동일 mutex 구간에서 `delete(s.books, req.AccountID)`로 기존 order book을 제거한다. `GetPaperTradingState`가 book 우선이므로, 재시작 후에는 book이 없어 새 run seed가 노출되고 다음 submit는 fresh sequence(`paper-order-<acct>-1`)로 시작한다.
|
||||
- **REVIEW_PAPER_ORDER-2 (decimal 검증)**: `papertrading.ValidateOrderDecimal(label, value, requirePositive)`를 추가했다. `math/big.Rat`로 파싱해 비숫자를 거르고, quantity는 `> 0`, limit/fill price는 `>= 0`을 강제한다. 오류는 `ErrInvalidOrderInput`을 wrap하고, worker socket의 `paperBackendErrorInfo`가 이를 `invalid_request`로 매핑한다.
|
||||
- boundary(빠른 거절): socket `quantityFromProto`/`orderIntentFromProto`(limit price)/`handleFillPaperOrder`(fill price)에서 검증해 malformed 입력이 domain order로 만들어지기 전에 `invalid_request`로 거절한다.
|
||||
- service(방어): `validateOrderIntent`(quantity/limit price)와 `FillPaperOrder`(fill price)에서 동일 검증을 수행해 direct package caller가 malformed `PaperOrder`를 저장/체결할 수 없게 한다.
|
||||
- rejection 상태는 risk/cash·position 같은 business denial 전용으로 유지하고, malformed request 데이터는 rejection이 아니라 `invalid_request`로 처리한다.
|
||||
- **기존 동작 보존**: 공유 헬퍼 `priceFromProto`(starting_cash에도 사용)는 변경하지 않아 start_paper_trading의 cash 검증 동작이 그대로 유지된다. 추가 검증은 order lifecycle 경로에만 적용된다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Restarting the same paper account must not expose the previous order book snapshot or sequence.
|
||||
- Invalid lifecycle quantity/price inputs must become typed `invalid_request` before order state mutation.
|
||||
- Rejection status remains reserved for business denials such as risk or insufficient cash/position, not malformed request data.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
|
||||
검증은 현재 local checkout에서 실행했다. `bin/test`의 contract drift 검사는 커밋된 generated 코드와 동일한 protoc v5.29.3(`/tmp/protoc293/bin`)을 PATH 앞에 두고 돌렸다(시스템 protoc은 3.21.12). go vet도 변경 패키지에서 통과(`go vet ./services/worker/internal/papertrading/ ./services/worker/internal/socket/ ./apps/cli/internal/operator/` → exit 0).
|
||||
|
||||
### REVIEW_PAPER_ORDER-1 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./services/worker/internal/papertrading
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
|
||||
```
|
||||
restart regression test:
|
||||
```text
|
||||
--- PASS: TestServiceStartClearsOrderBook (0.00s)
|
||||
```
|
||||
|
||||
### REVIEW_PAPER_ORDER-2 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.059s
|
||||
|
||||
$ go test -count=1 ./apps/cli/internal/operator
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.162s
|
||||
```
|
||||
신규 invalid-input regression tests:
|
||||
```text
|
||||
--- PASS: TestServiceSubmitRejectsInvalidQuantity (0.00s) # 0 / -1 / abc / 공백
|
||||
--- PASS: TestServiceSubmitRejectsInvalidLimitPrice (0.00s) # -1 / abc
|
||||
--- PASS: TestServiceFillRejectsInvalidFillPrice (0.00s) # -1 / abc, 이후 유효가 격으로 재체결 성공
|
||||
--- PASS: TestHandleSubmitPaperOrderInvalidDecimal (0.00s) # socket: quantity/limit_price -> invalid_request
|
||||
--- PASS: TestHandleFillPaperOrderInvalidFillPrice (0.00s) # socket: fill_price -> invalid_request
|
||||
--- PASS: TestRunSubmitPaperOrderTypedError (0.00s) # CLI: typed invalid_request 표면화
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.059s
|
||||
|
||||
$ go test -count=1 ./apps/cli/internal/operator
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.162s
|
||||
|
||||
$ PATH="/tmp/protoc293/bin:$PATH" bin/test
|
||||
...(contracts-check + 5 Go 모듈 go test ./... + flutter test)
|
||||
00:02 +60: All tests passed!
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## Ownership
|
||||
|
||||
| 섹션 | 소유자 | 설명 |
|
||||
|------|--------|------|
|
||||
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
|
||||
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
|
||||
| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 |
|
||||
| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크; 마지막 체크박스는 저장 전 필수 |
|
||||
| 코드리뷰 전용 체크리스트 | 리뷰 에이전트 | 구현 에이전트가 수정하지 않음 |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | placeholder를 실제 내용으로 교체 |
|
||||
| 사용자 리뷰 요청 | 구현 에이전트 | 필요 없으면 `상태: 없음` 유지 |
|
||||
| 검증 결과 | 구현 에이전트 | 실제 stdout/stderr 기록 |
|
||||
| 코드리뷰 결과 | 리뷰 에이전트 | 판정 append |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
판정: PASS
|
||||
|
||||
### 차원별 평가
|
||||
|
||||
| 차원 | 평가 | 근거 |
|
||||
|------|------|------|
|
||||
| Correctness | Pass | `StartPaperTrading`이 새 state 기록과 같은 mutex 구간에서 기존 order book을 삭제해 restart 후 stale snapshot/sequence가 남지 않는다. |
|
||||
| API contract / Error mapping | Pass | quantity/limit_price/fill_price malformed/invalid sign 입력이 service/socket boundary에서 `ErrInvalidOrderInput` 또는 typed `invalid_request`로 귀결된다. |
|
||||
| Scope | Pass | 이전 FAIL의 Required 2건에 집중했고, starting_cash 등 follow-up 범위 밖 동작은 건드리지 않았다. |
|
||||
| Tests | Pass | restart 회귀, service defensive validation, socket invalid_request, CLI typed-error surface가 추가되어 요청된 검증 범위를 충족한다. |
|
||||
|
||||
### Findings
|
||||
|
||||
- Required: 없음
|
||||
- Suggested: 없음
|
||||
- Nit: 없음
|
||||
|
||||
### 리뷰 메모
|
||||
|
||||
- `services/worker/internal/papertrading/service.go:134`에서 `states[accountID]` 갱신 직후 `delete(s.books, accountID)`가 수행되어 `GetPaperTradingState`의 book-first 조회와 order id sequence stale 문제가 해소됐다.
|
||||
- `services/worker/internal/papertrading/service.go:466`의 `ValidateOrderDecimal`과 socket mapping/handler 검증 조합으로 malformed quantity, zero/negative quantity, malformed/negative price가 order mutation 전에 차단된다.
|
||||
- malformed request와 business rejection의 경계가 유지된다. 입력 형식 문제는 typed error, risk/cash/position denial은 `rejected` order status로 남는다.
|
||||
|
||||
### 리뷰어 재검증
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
|
||||
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.058s
|
||||
|
||||
$ go test -count=1 ./apps/cli/internal/operator
|
||||
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.159s
|
||||
|
||||
$ go vet ./services/worker/internal/papertrading/ ./services/worker/internal/socket/ ./apps/cli/internal/operator/
|
||||
exit 0
|
||||
|
||||
$ git diff --check
|
||||
exit 0
|
||||
|
||||
$ PATH="/tmp/protoc293/bin:$PATH" bin/test
|
||||
00:02 +60: All tests passed!
|
||||
```
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Complete - m-paper-trading-command-workflow/03+02_order_lifecycle
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-05
|
||||
|
||||
## 요약
|
||||
|
||||
Paper order lifecycle command 작업은 2회 리뷰 루프 후 최종 PASS로 완료됐다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G09_0.log` | `code_review_cloud_G09_0.log` | FAIL | restart stale order book, lifecycle decimal invalid_request 검증 누락 Required 2건 발견 |
|
||||
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | PASS | restart order book reset과 lifecycle decimal validation 보강 후 재검증 통과 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- Paper order lifecycle submit/cancel/fill contract, worker/API/CLI surface, operator fixture를 연결했다.
|
||||
- 같은 account를 다시 start할 때 기존 in-memory order book을 삭제해 새 run state와 fresh order sequence가 노출되도록 보강했다.
|
||||
- quantity, limit_price, fill_price lifecycle decimal을 order mutation 전에 검증하고 malformed input을 typed `invalid_request`로 매핑했다.
|
||||
- service/socket/CLI 회귀 테스트로 restart, invalid decimal, typed-error surface를 고정했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket` - PASS; papertrading/socket package tests passed.
|
||||
- `go test -count=1 ./apps/cli/internal/operator` - PASS; CLI operator tests passed.
|
||||
- `go vet ./services/worker/internal/papertrading/ ./services/worker/internal/socket/ ./apps/cli/internal/operator/` - PASS; exit 0.
|
||||
- `git diff --check` - PASS; exit 0.
|
||||
- `PATH="/tmp/protoc293/bin:$PATH" bin/test` - PASS; Go packages and Flutter tests passed, `00:02 +60: All tests passed!`.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Completed task ids:
|
||||
- `paper-order-command`: PASS; evidence=`plan_cloud_G09_0.log`, `code_review_cloud_G09_0.log`, `plan_local_G06_1.log`, `code_review_local_G06_1.log`; verification=`go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket`, `go test -count=1 ./apps/cli/internal/operator`, `PATH="/tmp/protoc293/bin:$PATH" bin/test`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<!-- task=m-paper-trading-command-workflow/03+02_order_lifecycle plan=1 tag=REVIEW_PAPER_ORDER -->
|
||||
|
||||
# Plan - REVIEW_PAPER_ORDER
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
이 plan은 `code_review_cloud_G09_0.log`의 FAIL 판정에서 나온 Required 이슈만 처리한다. 사용자에게 직접 질문하지 말고, 사용자 결정이 꼭 필요한 경우에만 `CODE_REVIEW-local-G06.md`의 `사용자 리뷰 요청`을 증거와 함께 채운 뒤 중단한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Task ids:
|
||||
- `paper-order-command`: virtual order lifecycle command
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 배경
|
||||
|
||||
order lifecycle 구현은 핵심 happy path와 contract/API/CLI 연결은 갖췄지만, 리뷰에서 두 가지 correctness/API contract 문제가 발견됐다.
|
||||
|
||||
- 같은 account를 다시 start하면 기존 order book이 남아 새 run state를 가린다.
|
||||
- order lifecycle decimal 입력이 non-empty 여부만 검사해 invalid quantity/price가 pending order로 저장되거나 fill 시점에 rejected/internal error로 흘러간다.
|
||||
|
||||
## 범위 결정 근거
|
||||
|
||||
새 contract, 새 command surface, UI handoff, real API/DB-backed scenario 실행은 이 follow-up 범위가 아니다. 이미 추가된 order lifecycle surface 안에서 상태 초기화와 invalid request 검증만 보강한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [REVIEW_PAPER_ORDER-1] `StartPaperTrading`이 같은 account의 기존 order book을 새 run state와 함께 초기화하고, restart regression test를 추가한다.
|
||||
- [ ] [REVIEW_PAPER_ORDER-2] order lifecycle quantity/price decimal을 저장/체결 전에 typed invalid request로 검증하고, malformed/zero/negative input regression tests를 추가한다. 검증: `go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket`, `go test -count=1 ./apps/cli/internal/operator`, `PATH="/tmp/protoc293/bin:$PATH" bin/test` 통과.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## REVIEW_PAPER_ORDER-1
|
||||
|
||||
### 문제
|
||||
|
||||
`services/worker/internal/papertrading/service.go:127` records a new `states[accountID]` entry but leaves `books[accountID]` untouched. Because `GetPaperTradingState` checks `books` first, a restart after manual submit/fill continues to expose the old order-book snapshot and order sequence.
|
||||
|
||||
### 해결 방법
|
||||
|
||||
Inside the same mutex section that records the new state, delete the account's order book. Add a regression test that:
|
||||
|
||||
- starts a paper account
|
||||
- submits and fills or cancels a manual order so an order book exists
|
||||
- starts the same account again
|
||||
- verifies `GetPaperTradingState` reflects the new run seed rather than the old manual state
|
||||
- verifies the next submitted order starts from the fresh book sequence
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/worker/internal/papertrading/service.go`
|
||||
- [ ] `services/worker/internal/papertrading/service_test.go`
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./services/worker/internal/papertrading
|
||||
```
|
||||
|
||||
## REVIEW_PAPER_ORDER-2
|
||||
|
||||
### 문제
|
||||
|
||||
`services/worker/internal/papertrading/service.go:450` only checks `quantity.Amount.Value != ""`, and `services/worker/internal/socket/paper_mapping.go:49` only checks non-empty price amounts. Invalid decimals such as `abc`, zero quantity, negative quantity, and malformed/negative price can enter the lifecycle and later surface as rejected orders or internal fill errors instead of typed `invalid_request`.
|
||||
|
||||
### 해결 방법
|
||||
|
||||
Validate order lifecycle decimals before state mutation:
|
||||
|
||||
- `quantity` must parse as a decimal and be `> 0`
|
||||
- `limit_price` and market `fill_price`, when present, must parse as decimal and be non-negative
|
||||
- malformed `quantity`, `limit_price`, and `fill_price` must map to typed `invalid_request` at the worker socket boundary
|
||||
- service-level validation should remain defensive so direct package callers cannot store malformed `PaperOrder`
|
||||
|
||||
Add regression tests at the lowest meaningful layer:
|
||||
|
||||
- service submit rejects zero, negative, and non-numeric quantity
|
||||
- worker socket submit returns `invalid_request` for malformed quantity/limit price
|
||||
- worker socket fill returns `invalid_request` for malformed or negative fill price
|
||||
- CLI operator test covers typed invalid_request output for at least one lifecycle invalid input, or existing lower-level socket tests make the CLI path unnecessary and the review notes why
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/worker/internal/papertrading/service.go`
|
||||
- [ ] `services/worker/internal/papertrading/service_test.go`
|
||||
- [ ] `services/worker/internal/socket/paper_mapping.go`
|
||||
- [ ] `services/worker/internal/socket/paper_test.go`
|
||||
- [ ] `apps/cli/internal/operator/runner_paper_test.go` if CLI typed error evidence is added
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket
|
||||
go test -count=1 ./apps/cli/internal/operator
|
||||
```
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./services/worker/internal/papertrading ./services/worker/internal/socket
|
||||
go test -count=1 ./apps/cli/internal/operator
|
||||
PATH="/tmp/protoc293/bin:$PATH" bin/test
|
||||
```
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
<!-- task=m-paper-trading-command-workflow/02+01_risk_command plan=0 tag=PAPER_RISK -->
|
||||
|
||||
# Code Review Reference - PAPER_RISK
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> Complete implementation-owned sections, paste actual verification output, and stop with active files in place for review.
|
||||
> If user-only input is required, fill `사용자 리뷰 요청` and do not ask the user directly.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-05
|
||||
task=m-paper-trading-command-workflow/02+01_risk_command, plan=0, tag=PAPER_RISK
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Task ids:
|
||||
- `paper-risk-command`: risk guard command workflow
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** PASS/WARN/FAIL 판정, log archive, `complete.log`, task archive는 리뷰 에이전트 전용이다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [PAPER_RISK-1] risk state contract and worker mapping | [ ] |
|
||||
| [PAPER_RISK-2] CLI risk output keys | [ ] |
|
||||
| [PAPER_RISK-3] risk fixture/tests | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [PAPER_RISK-1] `Snapshot.Rejected`를 service state와 protobuf state로 additive하게 노출한다.
|
||||
- [ ] [PAPER_RISK-2] worker/API/CLI mapping이 risk summary를 stable key로 출력하게 한다.
|
||||
- [ ] [PAPER_RISK-3] risk allow/block fixture 또는 fake API test가 typed status/error key를 검증하게 한다. 검증: `bin/contracts-check`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator` 통과.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
|
||||
- [ ] PASS/WARN/FAIL 판정을 append한다.
|
||||
- [ ] active files를 `.log`로 아카이브하고 PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
- [ ] PASS이면 roadmap completion metadata를 보고한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- protobuf changes must be additive and generated outputs must match.
|
||||
- risk summary must distinguish clear vs blocked without faking broker behavior.
|
||||
- API must stay thin and worker-owned runtime must remain worker-owned.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### PAPER_RISK-1 중간 검증
|
||||
```text
|
||||
$ bin/contracts-check
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ bin/test
|
||||
(output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
<!-- task=m-paper-trading-command-workflow/03+02_order_lifecycle plan=0 tag=PAPER_ORDER -->
|
||||
|
||||
# Code Review Reference - PAPER_ORDER
|
||||
|
||||
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
|
||||
> Complete implementation-owned sections, paste actual verification output, and stop with active files in place for review.
|
||||
> Do not ask the user directly; use `사용자 리뷰 요청` for user-only blockers.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-06-05
|
||||
task=m-paper-trading-command-workflow/03+02_order_lifecycle, plan=0, tag=PAPER_ORDER
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md`
|
||||
- Task ids:
|
||||
- `paper-order-command`: virtual order lifecycle command
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** PASS/WARN/FAIL 판정과 archive는 리뷰 에이전트 전용이다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [PAPER_ORDER-1] paper order contract | [ ] |
|
||||
| [PAPER_ORDER-2] worker order lifecycle state | [ ] |
|
||||
| [PAPER_ORDER-3] API/CLI lifecycle scenario | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [PAPER_ORDER-1] protobuf contract에 paper order command/query messages를 additive하게 추가하고 parser maps/client methods를 갱신한다.
|
||||
- [ ] [PAPER_ORDER-2] worker paper service가 virtual order submit/cancel/fill simulation state를 in-memory로 관리하고 typed status를 반환한다.
|
||||
- [ ] [PAPER_ORDER-3] API pass-through와 CLI scenario action이 order id, status transition, fill summary를 출력한다. 검증: `bin/contracts-check`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/internal/operator`, `bin/test` 통과.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
|
||||
- [ ] PASS/WARN/FAIL 판정을 append한다.
|
||||
- [ ] active files를 `.log`로 아카이브하고 PASS이면 `complete.log` 작성 후 task directory를 archive로 이동한다.
|
||||
- [ ] PASS이면 roadmap completion metadata를 보고한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- No real broker behavior is introduced.
|
||||
- Order state transitions are deterministic and tested.
|
||||
- API remains pass-through; worker owns runtime state.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### PAPER_ORDER-1 중간 검증
|
||||
```text
|
||||
$ bin/contracts-check
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ bin/test
|
||||
(output)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
|
||||
|
|
@ -156,6 +156,21 @@ func (c *APIClient) GetPaperTradingState(ctx context.Context, req *altv1.GetPape
|
|||
return sendTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
// SubmitPaperOrder submits a virtual paper order.
|
||||
func (c *APIClient) SubmitPaperOrder(ctx context.Context, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
return sendTyped[*altv1.SubmitPaperOrderRequest, *altv1.SubmitPaperOrderResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
// CancelPaperOrder cancels a pending virtual paper order.
|
||||
func (c *APIClient) CancelPaperOrder(ctx context.Context, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
return sendTyped[*altv1.CancelPaperOrderRequest, *altv1.CancelPaperOrderResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
// FillPaperOrder simulates the fill of a pending virtual paper order.
|
||||
func (c *APIClient) FillPaperOrder(ctx context.Context, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
return sendTyped[*altv1.FillPaperOrderRequest, *altv1.FillPaperOrderResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
// sendTyped is the shared request path for every API call. It derives the
|
||||
// request timeout from the context deadline and maps a dropped connection to
|
||||
// ErrTransport so each method stays a one-line forwarder and the transport vs.
|
||||
|
|
|
|||
|
|
@ -32,18 +32,24 @@ type fakeAPI struct {
|
|||
importDailyBarsResp *altv1.ImportDailyBarsResponse
|
||||
startPaperResp *altv1.StartPaperTradingResponse
|
||||
getPaperStateResp *altv1.GetPaperTradingStateResponse
|
||||
submitPaperOrderResp *altv1.SubmitPaperOrderResponse
|
||||
cancelPaperOrderResp *altv1.CancelPaperOrderResponse
|
||||
fillPaperOrderResp *altv1.FillPaperOrderResponse
|
||||
|
||||
getBacktestRunFunc func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error)
|
||||
|
||||
mu sync.Mutex
|
||||
instReq *altv1.ListInstrumentsRequest
|
||||
barsReq *altv1.ListBarsRequest
|
||||
startBacktestReq *altv1.StartBacktestRequest
|
||||
getBacktestRunReq *altv1.GetBacktestRunRequest
|
||||
importDailyBarsReq *altv1.ImportDailyBarsRequest
|
||||
startPaperReq *altv1.StartPaperTradingRequest
|
||||
getPaperStateReq *altv1.GetPaperTradingStateRequest
|
||||
calls []string
|
||||
mu sync.Mutex
|
||||
instReq *altv1.ListInstrumentsRequest
|
||||
barsReq *altv1.ListBarsRequest
|
||||
startBacktestReq *altv1.StartBacktestRequest
|
||||
getBacktestRunReq *altv1.GetBacktestRunRequest
|
||||
importDailyBarsReq *altv1.ImportDailyBarsRequest
|
||||
startPaperReq *altv1.StartPaperTradingRequest
|
||||
getPaperStateReq *altv1.GetPaperTradingStateRequest
|
||||
submitPaperOrderReq *altv1.SubmitPaperOrderRequest
|
||||
cancelPaperOrderReq *altv1.CancelPaperOrderRequest
|
||||
fillPaperOrderReq *altv1.FillPaperOrderRequest
|
||||
calls []string
|
||||
}
|
||||
|
||||
// recordCall appends an action name in receive order so tests can assert that a
|
||||
|
|
@ -144,6 +150,42 @@ func (f *fakeAPI) lastGetPaperStateReq() *altv1.GetPaperTradingStateRequest {
|
|||
return f.getPaperStateReq
|
||||
}
|
||||
|
||||
func (f *fakeAPI) setSubmitPaperOrderReq(req *altv1.SubmitPaperOrderRequest) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.submitPaperOrderReq = req
|
||||
}
|
||||
|
||||
func (f *fakeAPI) lastSubmitPaperOrderReq() *altv1.SubmitPaperOrderRequest {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.submitPaperOrderReq
|
||||
}
|
||||
|
||||
func (f *fakeAPI) setCancelPaperOrderReq(req *altv1.CancelPaperOrderRequest) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.cancelPaperOrderReq = req
|
||||
}
|
||||
|
||||
func (f *fakeAPI) lastCancelPaperOrderReq() *altv1.CancelPaperOrderRequest {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.cancelPaperOrderReq
|
||||
}
|
||||
|
||||
func (f *fakeAPI) setFillPaperOrderReq(req *altv1.FillPaperOrderRequest) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.fillPaperOrderReq = req
|
||||
}
|
||||
|
||||
func (f *fakeAPI) lastFillPaperOrderReq() *altv1.FillPaperOrderRequest {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.fillPaperOrderReq
|
||||
}
|
||||
|
||||
// startFakeAPIServer starts a proto-socket server on a free localhost port that
|
||||
// answers hello/list_instruments/list_bars with the supplied canned responses
|
||||
// and records the received requests. It returns the ws:// URL and stops the
|
||||
|
|
@ -251,6 +293,30 @@ func startFakeAPIServer(t *testing.T, api *fakeAPI) string {
|
|||
}
|
||||
return &altv1.GetPaperTradingStateResponse{}, nil
|
||||
})
|
||||
protoSocket.AddRequestListenerTyped[*altv1.SubmitPaperOrderRequest, *altv1.SubmitPaperOrderResponse](&c.Communicator, func(req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
api.recordCall("submit_paper_order")
|
||||
api.setSubmitPaperOrderReq(req)
|
||||
if api.submitPaperOrderResp != nil {
|
||||
return api.submitPaperOrderResp, nil
|
||||
}
|
||||
return &altv1.SubmitPaperOrderResponse{}, nil
|
||||
})
|
||||
protoSocket.AddRequestListenerTyped[*altv1.CancelPaperOrderRequest, *altv1.CancelPaperOrderResponse](&c.Communicator, func(req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
api.recordCall("cancel_paper_order")
|
||||
api.setCancelPaperOrderReq(req)
|
||||
if api.cancelPaperOrderResp != nil {
|
||||
return api.cancelPaperOrderResp, nil
|
||||
}
|
||||
return &altv1.CancelPaperOrderResponse{}, nil
|
||||
})
|
||||
protoSocket.AddRequestListenerTyped[*altv1.FillPaperOrderRequest, *altv1.FillPaperOrderResponse](&c.Communicator, func(req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
api.recordCall("fill_paper_order")
|
||||
api.setFillPaperOrderReq(req)
|
||||
if api.fillPaperOrderResp != nil {
|
||||
return api.fillPaperOrderResp, nil
|
||||
}
|
||||
return &altv1.FillPaperOrderResponse{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := srv.Start(ctx); err != nil {
|
||||
|
|
|
|||
|
|
@ -51,11 +51,26 @@ type StepEvent struct {
|
|||
FillCount int
|
||||
Risk string
|
||||
|
||||
// Paper risk summary. Risk is "clear" when no order was rejected and
|
||||
// "blocked" otherwise. RiskRejectedCount and RiskReason are only rendered for
|
||||
// a blocked run so a clear run keeps stable, minimal output.
|
||||
RiskRejectedCount int
|
||||
RiskReason string
|
||||
|
||||
// Paper loop equity summary. EquityPointCount is the length of the paper
|
||||
// equity curve and LatestEquity is the equity amount of its last point.
|
||||
// They are omitted for an empty curve so loop smoke evidence stays stable.
|
||||
EquityPointCount int
|
||||
LatestEquity string
|
||||
|
||||
// Paper order lifecycle output fields. They render for submit/cancel/fill
|
||||
// steps, keyed on OrderID, independently of the paper-state account block.
|
||||
// OrderFillPrice/OrderFillCount carry the fill summary of a filled order.
|
||||
OrderID string
|
||||
OrderStatus string
|
||||
OrderReason string
|
||||
OrderFillPrice string
|
||||
OrderFillCount int
|
||||
}
|
||||
|
||||
// RunSummary is the final record describing a whole scenario run.
|
||||
|
|
@ -138,6 +153,12 @@ func (o *Writer) WriteStep(ev StepEvent) {
|
|||
if ev.Risk != "" {
|
||||
fields["risk"] = ev.Risk
|
||||
}
|
||||
if ev.RiskRejectedCount > 0 {
|
||||
fields["risk_rejected_count"] = ev.RiskRejectedCount
|
||||
}
|
||||
if ev.RiskReason != "" {
|
||||
fields["risk_reason"] = ev.RiskReason
|
||||
}
|
||||
if ev.EquityPointCount > 0 {
|
||||
fields["equity_point_count"] = ev.EquityPointCount
|
||||
}
|
||||
|
|
@ -145,6 +166,21 @@ func (o *Writer) WriteStep(ev StepEvent) {
|
|||
fields["latest_equity"] = ev.LatestEquity
|
||||
}
|
||||
}
|
||||
if ev.OrderID != "" {
|
||||
fields["order_id"] = ev.OrderID
|
||||
if ev.OrderStatus != "" {
|
||||
fields["order_status"] = ev.OrderStatus
|
||||
}
|
||||
if ev.OrderReason != "" {
|
||||
fields["order_reason"] = ev.OrderReason
|
||||
}
|
||||
if ev.Action == string(ActionFillPaperOrder) {
|
||||
fields["fill_count"] = ev.OrderFillCount
|
||||
}
|
||||
if ev.OrderFillPrice != "" {
|
||||
fields["fill_price"] = ev.OrderFillPrice
|
||||
}
|
||||
}
|
||||
o.writeJSON(fields)
|
||||
return
|
||||
}
|
||||
|
|
@ -195,6 +231,12 @@ func (o *Writer) WriteStep(ev StepEvent) {
|
|||
if ev.Risk != "" {
|
||||
line += fmt.Sprintf(" risk=%s", ev.Risk)
|
||||
}
|
||||
if ev.RiskRejectedCount > 0 {
|
||||
line += fmt.Sprintf(" risk_rejected_count=%d", ev.RiskRejectedCount)
|
||||
}
|
||||
if ev.RiskReason != "" {
|
||||
line += fmt.Sprintf(" risk_reason=%q", ev.RiskReason)
|
||||
}
|
||||
if ev.EquityPointCount > 0 {
|
||||
line += fmt.Sprintf(" equity_point_count=%d", ev.EquityPointCount)
|
||||
}
|
||||
|
|
@ -202,6 +244,21 @@ func (o *Writer) WriteStep(ev StepEvent) {
|
|||
line += fmt.Sprintf(" latest_equity=%s", ev.LatestEquity)
|
||||
}
|
||||
}
|
||||
if ev.OrderID != "" {
|
||||
line += fmt.Sprintf(" order_id=%s", ev.OrderID)
|
||||
if ev.OrderStatus != "" {
|
||||
line += fmt.Sprintf(" order_status=%s", ev.OrderStatus)
|
||||
}
|
||||
if ev.OrderReason != "" {
|
||||
line += fmt.Sprintf(" order_reason=%q", ev.OrderReason)
|
||||
}
|
||||
if ev.Action == string(ActionFillPaperOrder) {
|
||||
line += fmt.Sprintf(" fill_count=%d", ev.OrderFillCount)
|
||||
}
|
||||
if ev.OrderFillPrice != "" {
|
||||
line += fmt.Sprintf(" fill_price=%s", ev.OrderFillPrice)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(o.w, line)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@ func messageFactories() []func() proto.Message {
|
|||
func() proto.Message { return &altv1.GetPaperTradingStateRequest{} },
|
||||
func() proto.Message { return &altv1.GetPaperTradingStateResponse{} },
|
||||
func() proto.Message { return &altv1.PaperTradingState{} },
|
||||
// paper order lifecycle surface: submit / cancel / fill
|
||||
func() proto.Message { return &altv1.SubmitPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.SubmitPaperOrderResponse{} },
|
||||
func() proto.Message { return &altv1.CancelPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.CancelPaperOrderResponse{} },
|
||||
func() proto.Message { return &altv1.FillPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.FillPaperOrderResponse{} },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,13 @@ func TestParserMapIncludesRunnerMessages(t *testing.T) {
|
|||
&altv1.GetPaperTradingStateRequest{},
|
||||
&altv1.GetPaperTradingStateResponse{},
|
||||
&altv1.PaperTradingState{},
|
||||
// paper order lifecycle surface: submit / cancel / fill
|
||||
&altv1.SubmitPaperOrderRequest{},
|
||||
&altv1.SubmitPaperOrderResponse{},
|
||||
&altv1.CancelPaperOrderRequest{},
|
||||
&altv1.CancelPaperOrderResponse{},
|
||||
&altv1.FillPaperOrderRequest{},
|
||||
&altv1.FillPaperOrderResponse{},
|
||||
}
|
||||
for _, m := range required {
|
||||
name := protoSocket.TypeNameOf(m)
|
||||
|
|
|
|||
|
|
@ -425,6 +425,55 @@ func runStep(ctx context.Context, client *APIClient, scenario string, step Step,
|
|||
ev2, code := evaluatePaper(ev, step, resp.GetError(), resp.GetState())
|
||||
return ev2, code, ""
|
||||
|
||||
case ActionSubmitPaperOrder:
|
||||
req := &altv1.SubmitPaperOrderRequest{
|
||||
AccountId: step.Request.AccountID,
|
||||
InstrumentId: step.Request.InstrumentID,
|
||||
Side: step.Request.Side,
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: step.Request.Quantity}},
|
||||
Type: step.Request.OrderType,
|
||||
}
|
||||
if step.Request.OrderType == "limit" {
|
||||
req.LimitPrice = &altv1.Price{
|
||||
Currency: currencyByMarket[step.Request.Market],
|
||||
Amount: &altv1.Decimal{Value: step.Request.LimitPrice},
|
||||
}
|
||||
}
|
||||
resp, err := client.SubmitPaperOrder(stepCtx, req)
|
||||
if err != nil {
|
||||
return transportEvent3(ev, err)
|
||||
}
|
||||
return evaluatePaperOrder(ev, step, resp.GetError(), resp.GetOrder())
|
||||
|
||||
case ActionCancelPaperOrder:
|
||||
orderID := interpolate(step.Request.OrderID, savedValues)
|
||||
resp, err := client.CancelPaperOrder(stepCtx, &altv1.CancelPaperOrderRequest{
|
||||
AccountId: step.Request.AccountID,
|
||||
OrderId: orderID,
|
||||
})
|
||||
if err != nil {
|
||||
return transportEvent3(ev, err)
|
||||
}
|
||||
return evaluatePaperOrder(ev, step, resp.GetError(), resp.GetOrder())
|
||||
|
||||
case ActionFillPaperOrder:
|
||||
orderID := interpolate(step.Request.OrderID, savedValues)
|
||||
fillReq := &altv1.FillPaperOrderRequest{
|
||||
AccountId: step.Request.AccountID,
|
||||
OrderId: orderID,
|
||||
}
|
||||
if step.Request.FillPrice != "" {
|
||||
fillReq.FillPrice = &altv1.Price{
|
||||
Currency: currencyByMarket[step.Request.Market],
|
||||
Amount: &altv1.Decimal{Value: step.Request.FillPrice},
|
||||
}
|
||||
}
|
||||
resp, err := client.FillPaperOrder(stepCtx, fillReq)
|
||||
if err != nil {
|
||||
return transportEvent3(ev, err)
|
||||
}
|
||||
return evaluatePaperOrder(ev, step, resp.GetError(), resp.GetOrder())
|
||||
|
||||
default:
|
||||
// Validation rejects unknown actions, so reaching here means a runner is
|
||||
// missing for a registered action.
|
||||
|
|
@ -643,7 +692,16 @@ func evaluatePaper(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, state *alt
|
|||
ev.Cash = state.GetCash().GetAmount().GetValue()
|
||||
ev.PositionCount = len(state.GetPositions())
|
||||
ev.FillCount = len(state.GetFills())
|
||||
ev.Risk = "not_evaluated"
|
||||
// Risk is derived from the worker-owned rejection summary: an empty list
|
||||
// means every decided order cleared risk, a non-empty list means at least
|
||||
// one order was blocked. The first reason is surfaced so blocked output
|
||||
// carries a stable, human-readable cause without faking broker behaviour.
|
||||
ev.Risk = "clear"
|
||||
if rejections := state.GetRiskRejections(); len(rejections) > 0 {
|
||||
ev.Risk = "blocked"
|
||||
ev.RiskRejectedCount = len(rejections)
|
||||
ev.RiskReason = rejections[0].GetReason()
|
||||
}
|
||||
if run := state.GetRun(); run != nil {
|
||||
ev.RunID = run.GetId()
|
||||
ev.RunStatus = strings.ToLower(strings.TrimPrefix(run.GetStatus().String(), "BACKTEST_RUN_STATUS_"))
|
||||
|
|
@ -671,6 +729,60 @@ func evaluatePaper(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, state *alt
|
|||
return ev, codeOK
|
||||
}
|
||||
|
||||
// evaluatePaperOrder checks a paper order lifecycle response. order is the typed
|
||||
// PaperOrder (nil on a typed error). The returned string is the order id, saved
|
||||
// so a later cancel/fill step can interpolate {{steps.<id>.order.id}}. A risk or
|
||||
// portfolio denial is a successful response carrying order_status=rejected, not a
|
||||
// transport/typed error, so the operator sees the status transition.
|
||||
func evaluatePaperOrder(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, order *altv1.PaperOrder) (StepEvent, int, string) {
|
||||
expectsError := step.Expect.Status == statusError
|
||||
|
||||
var orderID string
|
||||
if order != nil {
|
||||
orderID = order.GetOrderId()
|
||||
ev.OrderID = order.GetOrderId()
|
||||
ev.OrderStatus = order.GetStatus()
|
||||
ev.OrderReason = order.GetReason()
|
||||
if fill := order.GetFill(); fill != nil {
|
||||
ev.OrderFillPrice = fill.GetPrice().GetAmount().GetValue()
|
||||
ev.OrderFillCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
if errInfo != nil {
|
||||
ev.ErrorCode = errInfo.GetCode()
|
||||
ev.ErrorMessage = errInfo.GetMessage()
|
||||
if !expectsError {
|
||||
ev.Status = statusError
|
||||
return ev, codeAppError, orderID
|
||||
}
|
||||
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
|
||||
ev.Status = statusMismatch
|
||||
return ev, codeAppError, orderID
|
||||
}
|
||||
ev.Status = statusOK
|
||||
return ev, codeOK, orderID
|
||||
}
|
||||
|
||||
if expectsError {
|
||||
ev.Status = statusMismatch
|
||||
ev.ErrorMessage = "expected a typed error but the request succeeded"
|
||||
return ev, codeAppError, orderID
|
||||
}
|
||||
|
||||
if step.Expect.OrderStatus != "" && order != nil {
|
||||
expectedStatus := strings.ToLower(step.Expect.OrderStatus)
|
||||
if order.GetStatus() != expectedStatus {
|
||||
ev.Status = statusMismatch
|
||||
ev.ErrorMessage = fmt.Sprintf("expected order status %q, got %q", expectedStatus, order.GetStatus())
|
||||
return ev, codeAppError, orderID
|
||||
}
|
||||
}
|
||||
|
||||
ev.Status = statusOK
|
||||
return ev, codeOK, orderID
|
||||
}
|
||||
|
||||
// missingCapabilities returns the first wanted capability absent from have, or
|
||||
// "" when all are present.
|
||||
func missingCapabilities(want, have []string) string {
|
||||
|
|
@ -691,8 +803,10 @@ func missingCapabilities(want, have []string) string {
|
|||
|
||||
func interpolate(val string, savedValues map[string]string) string {
|
||||
for k, v := range savedValues {
|
||||
placeholder := fmt.Sprintf("{{steps.%s.run.id}}", k)
|
||||
val = strings.ReplaceAll(val, placeholder, v)
|
||||
// A saved step value is the run id for backtest steps and the order id for
|
||||
// paper order steps; both placeholder forms resolve to the same value.
|
||||
val = strings.ReplaceAll(val, fmt.Sprintf("{{steps.%s.run.id}}", k), v)
|
||||
val = strings.ReplaceAll(val, fmt.Sprintf("{{steps.%s.order.id}}", k), v)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ func TestRunStartPaperTradingOutputsAccountState(t *testing.T) {
|
|||
"cash=9998950",
|
||||
"position_count=1",
|
||||
"fill_count=1",
|
||||
"risk=not_evaluated",
|
||||
"risk=clear",
|
||||
"equity_point_count=2",
|
||||
"latest_equity=10000100",
|
||||
} {
|
||||
|
|
@ -167,7 +167,7 @@ func TestRunGetPaperTradingStateOutputsFields(t *testing.T) {
|
|||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "account_id=paper-1") || !strings.Contains(out, "position_count=1") || !strings.Contains(out, "risk=not_evaluated") {
|
||||
if !strings.Contains(out, "account_id=paper-1") || !strings.Contains(out, "position_count=1") || !strings.Contains(out, "risk=clear") {
|
||||
t.Errorf("output %q missing paper state fields", out)
|
||||
}
|
||||
if !strings.Contains(out, "equity_point_count=2") || !strings.Contains(out, "latest_equity=10000100") {
|
||||
|
|
@ -208,3 +208,378 @@ func TestRunGetPaperTradingStateNotFoundTypedError(t *testing.T) {
|
|||
t.Errorf("output %q missing error.code=not_found", out)
|
||||
}
|
||||
}
|
||||
|
||||
// blockedPaperState mirrors paperState but carries a risk rejection so the
|
||||
// operator surface reports a blocked risk summary instead of a clear one.
|
||||
func blockedPaperState(accountID string) *altv1.PaperTradingState {
|
||||
state := paperState(accountID)
|
||||
state.RiskRejections = []*altv1.PaperRiskRejection{
|
||||
{
|
||||
InstrumentId: "KRX:000660",
|
||||
Side: "sell",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "2"}},
|
||||
Reason: "insufficient position quantity for sell order",
|
||||
TimestampUnixMs: 1746230400000,
|
||||
},
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func TestRunGetPaperTradingStateOutputsRiskFields(t *testing.T) {
|
||||
api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{State: blockedPaperState("paper-1")}}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "paper_trading_state",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "state",
|
||||
Action: ActionGetPaperTradingState,
|
||||
Request: Request{AccountID: "paper-1"},
|
||||
Expect: Expect{Status: "ok"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, code := runScenario(t, sc, url)
|
||||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"risk=blocked",
|
||||
"risk_rejected_count=1",
|
||||
`risk_reason="insufficient position quantity for sell order"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output %q missing %q", out, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "risk=clear") {
|
||||
t.Errorf("output %q should not report risk=clear for a blocked run", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- order lifecycle ---
|
||||
|
||||
func pendingOrder(orderID string) *altv1.PaperOrder {
|
||||
return &altv1.PaperOrder{
|
||||
OrderId: orderID,
|
||||
AccountId: "paper-1",
|
||||
InstrumentId: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
||||
Type: "market",
|
||||
Status: "pending",
|
||||
}
|
||||
}
|
||||
|
||||
func filledOrder(orderID, price string) *altv1.PaperOrder {
|
||||
order := pendingOrder(orderID)
|
||||
order.Status = "filled"
|
||||
order.Fill = &altv1.BacktestTrade{
|
||||
InstrumentId: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
||||
Price: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: price}},
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// TestRunPaperOrderLifecycle exercises submit -> fill across steps, asserting the
|
||||
// order id is surfaced, the status transitions pending -> filled, the fill
|
||||
// summary is emitted, and the order id is interpolated into the fill step.
|
||||
func TestRunPaperOrderLifecycle(t *testing.T) {
|
||||
api := &fakeAPI{
|
||||
submitPaperOrderResp: &altv1.SubmitPaperOrderResponse{Order: pendingOrder("paper-order-paper-1-1")},
|
||||
fillPaperOrderResp: &altv1.FillPaperOrderResponse{
|
||||
Order: filledOrder("paper-order-paper-1-1", "1000"),
|
||||
State: paperState("paper-1"),
|
||||
},
|
||||
}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "paper_order_lifecycle",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "submit",
|
||||
Action: ActionSubmitPaperOrder,
|
||||
Request: Request{
|
||||
AccountID: "paper-1",
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: "1",
|
||||
OrderType: "market",
|
||||
Market: "kr",
|
||||
},
|
||||
Expect: Expect{Status: "ok", OrderStatus: "pending"},
|
||||
},
|
||||
{
|
||||
ID: "fill",
|
||||
Action: ActionFillPaperOrder,
|
||||
Request: Request{
|
||||
AccountID: "paper-1",
|
||||
OrderID: "{{steps.submit.order.id}}",
|
||||
Market: "kr",
|
||||
FillPrice: "1000",
|
||||
},
|
||||
Expect: Expect{Status: "ok", OrderStatus: "filled"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, code := runScenario(t, sc, url)
|
||||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"step=submit action=submit_paper_order status=ok order_id=paper-order-paper-1-1 order_status=pending",
|
||||
"step=fill action=fill_paper_order status=ok order_id=paper-order-paper-1-1 order_status=filled fill_count=1 fill_price=1000",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output %q missing %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The fill step must have received the order id interpolated from the submit
|
||||
// response, not the raw placeholder.
|
||||
got := api.lastFillPaperOrderReq()
|
||||
if got == nil {
|
||||
t.Fatal("server did not receive a fill paper order request")
|
||||
}
|
||||
if got.GetOrderId() != "paper-order-paper-1-1" {
|
||||
t.Errorf("fill received order_id = %q, want interpolated paper-order-paper-1-1", got.GetOrderId())
|
||||
}
|
||||
if got.GetFillPrice().GetCurrency() != altv1.Currency_CURRENCY_KRW {
|
||||
t.Errorf("fill received currency = %v, want KRW (derived from kr market)", got.GetFillPrice().GetCurrency())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSubmitPaperOrderLimitForwardsLimitPrice(t *testing.T) {
|
||||
api := &fakeAPI{submitPaperOrderResp: &altv1.SubmitPaperOrderResponse{Order: pendingOrder("paper-order-paper-1-1")}}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "paper_order_lifecycle",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "submit",
|
||||
Action: ActionSubmitPaperOrder,
|
||||
Request: Request{
|
||||
AccountID: "paper-1",
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: "1",
|
||||
OrderType: "limit",
|
||||
LimitPrice: "900",
|
||||
Market: "kr",
|
||||
},
|
||||
Expect: Expect{Status: "ok"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, code := runScenario(t, sc, url)
|
||||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
got := api.lastSubmitPaperOrderReq()
|
||||
if got == nil {
|
||||
t.Fatal("server did not receive a submit paper order request")
|
||||
}
|
||||
if got.GetType() != "limit" {
|
||||
t.Errorf("submit received type = %q, want limit", got.GetType())
|
||||
}
|
||||
if got.GetLimitPrice().GetAmount().GetValue() != "900" || got.GetLimitPrice().GetCurrency() != altv1.Currency_CURRENCY_KRW {
|
||||
t.Errorf("submit received limit price = %+v, want 900 KRW", got.GetLimitPrice())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSubmitPaperOrderTypedError confirms the CLI surfaces a worker-returned
|
||||
// typed invalid_request for an order lifecycle step (e.g. malformed quantity).
|
||||
// The worker owns decimal validation; this asserts the thin CLI forward path
|
||||
// reports the typed error code rather than treating it as transport failure.
|
||||
func TestRunSubmitPaperOrderTypedError(t *testing.T) {
|
||||
api := &fakeAPI{submitPaperOrderResp: &altv1.SubmitPaperOrderResponse{
|
||||
Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "invalid paper trading request: invalid paper order input: quantity must be greater than zero"},
|
||||
}}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "paper_order_lifecycle",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "submit",
|
||||
Action: ActionSubmitPaperOrder,
|
||||
Request: Request{
|
||||
AccountID: "paper-1",
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: "0",
|
||||
OrderType: "market",
|
||||
Market: "kr",
|
||||
},
|
||||
Expect: Expect{Status: "error", ErrorCode: "invalid_request"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, code := runScenario(t, sc, url)
|
||||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "error.code=invalid_request") {
|
||||
t.Errorf("output %q missing error.code=invalid_request", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelPaperOrder(t *testing.T) {
|
||||
canceled := pendingOrder("paper-order-paper-1-1")
|
||||
canceled.Status = "canceled"
|
||||
api := &fakeAPI{cancelPaperOrderResp: &altv1.CancelPaperOrderResponse{Order: canceled}}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "paper_order_lifecycle",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "cancel",
|
||||
Action: ActionCancelPaperOrder,
|
||||
Request: Request{AccountID: "paper-1", OrderID: "paper-order-paper-1-1"},
|
||||
Expect: Expect{Status: "ok", OrderStatus: "canceled"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, code := runScenario(t, sc, url)
|
||||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "order_id=paper-order-paper-1-1 order_status=canceled") {
|
||||
t.Errorf("output %q missing canceled order line", out)
|
||||
}
|
||||
if strings.Contains(out, "fill_count") {
|
||||
t.Errorf("cancel output %q should not emit fill_count", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunFillPaperOrderRejectedSurfacesStatus confirms a risk/portfolio denial is
|
||||
// a successful response carrying order_status=rejected with fill_count=0, not a
|
||||
// transport or typed error.
|
||||
func TestRunFillPaperOrderRejectedSurfacesStatus(t *testing.T) {
|
||||
rejected := pendingOrder("paper-order-paper-1-1")
|
||||
rejected.Status = "rejected"
|
||||
rejected.Reason = "insufficient cash for fill"
|
||||
api := &fakeAPI{fillPaperOrderResp: &altv1.FillPaperOrderResponse{Order: rejected, State: paperState("paper-1")}}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "paper_order_lifecycle",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "fill",
|
||||
Action: ActionFillPaperOrder,
|
||||
Request: Request{AccountID: "paper-1", OrderID: "paper-order-paper-1-1", Market: "kr", FillPrice: "1000"},
|
||||
Expect: Expect{Status: "ok", OrderStatus: "rejected"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, code := runScenario(t, sc, url)
|
||||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"order_status=rejected",
|
||||
"fill_count=0",
|
||||
`order_reason="insufficient cash for fill"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output %q missing %q", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaperOrderLifecycleFixtureIsValid guards the order lifecycle scenario
|
||||
// fixture and its expected output: the YAML must pass dry-run validation and the
|
||||
// expected JSONL must carry the order id and a filled status transition.
|
||||
func TestPaperOrderLifecycleFixtureIsValid(t *testing.T) {
|
||||
yamlPath := filepath.Join("..", "..", "testdata", "operator", "paper_order_lifecycle.yaml")
|
||||
if _, err := LoadScenario(yamlPath); err != nil {
|
||||
t.Fatalf("paper_order_lifecycle.yaml failed validation: %v", err)
|
||||
}
|
||||
|
||||
expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "paper_order_lifecycle.jsonl")
|
||||
f, err := os.Open(expectedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open expected fixture: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
sawOrderID := false
|
||||
sawFilled := false
|
||||
sawSummary := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("expected fixture line not valid JSON: %v", err)
|
||||
}
|
||||
if rec["order_id"] != nil {
|
||||
sawOrderID = true
|
||||
}
|
||||
if rec["order_status"] == "filled" {
|
||||
sawFilled = true
|
||||
}
|
||||
if rec["type"] == "summary" {
|
||||
sawSummary = true
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scan expected fixture: %v", err)
|
||||
}
|
||||
if !sawOrderID {
|
||||
t.Error("expected fixture missing an order_id line")
|
||||
}
|
||||
if !sawFilled {
|
||||
t.Error("expected fixture missing an order_status=filled transition")
|
||||
}
|
||||
if !sawSummary {
|
||||
t.Error("expected fixture missing a summary line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStartPaperTradingClearRiskOmitsRejectionFields confirms a clear run
|
||||
// keeps minimal output: risk=clear with no risk_rejected_count/risk_reason keys.
|
||||
func TestRunStartPaperTradingClearRiskOmitsRejectionFields(t *testing.T) {
|
||||
api := &fakeAPI{startPaperResp: &altv1.StartPaperTradingResponse{State: paperState("paper-1")}}
|
||||
url := startFakeAPIServer(t, api)
|
||||
sc := &Scenario{
|
||||
Name: "paper_trading_state",
|
||||
Steps: []Step{
|
||||
{
|
||||
ID: "start",
|
||||
Action: ActionStartPaperTrading,
|
||||
Request: Request{
|
||||
AccountID: "paper-1",
|
||||
StrategyID: "strategy-v1",
|
||||
Market: "kr",
|
||||
Timeframe: "daily",
|
||||
FromUnixMs: 1746057600000,
|
||||
ToUnixMs: 1747267200000,
|
||||
StartingCash: "10000000",
|
||||
},
|
||||
Expect: Expect{Status: "ok", RunStatus: "succeeded"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, code := runScenario(t, sc, url)
|
||||
if code != codeOK {
|
||||
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
|
||||
}
|
||||
if !strings.Contains(out, "risk=clear") {
|
||||
t.Errorf("output %q missing risk=clear", out)
|
||||
}
|
||||
if strings.Contains(out, "risk_rejected_count") || strings.Contains(out, "risk_reason") {
|
||||
t.Errorf("clear run output %q should omit risk rejection detail keys", out)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ const (
|
|||
ActionStartPaperTrading Action = "start_paper_trading"
|
||||
// ActionGetPaperTradingState reads a paper trading account state.
|
||||
ActionGetPaperTradingState Action = "get_paper_trading_state"
|
||||
// ActionSubmitPaperOrder submits a virtual paper order.
|
||||
ActionSubmitPaperOrder Action = "submit_paper_order"
|
||||
// ActionCancelPaperOrder cancels a pending virtual paper order.
|
||||
ActionCancelPaperOrder Action = "cancel_paper_order"
|
||||
// ActionFillPaperOrder simulates the fill of a pending virtual paper order.
|
||||
ActionFillPaperOrder Action = "fill_paper_order"
|
||||
)
|
||||
|
||||
// validActions is the closed set used for strict dry-run validation.
|
||||
|
|
@ -61,6 +67,33 @@ var validActions = map[Action]bool{
|
|||
ActionPollBacktestRun: true,
|
||||
ActionStartPaperTrading: true,
|
||||
ActionGetPaperTradingState: true,
|
||||
ActionSubmitPaperOrder: true,
|
||||
ActionCancelPaperOrder: true,
|
||||
ActionFillPaperOrder: true,
|
||||
}
|
||||
|
||||
// validOrderSides is the set of order side strings a submit_paper_order may use.
|
||||
var validOrderSides = map[string]bool{
|
||||
"buy": true,
|
||||
"sell": true,
|
||||
}
|
||||
|
||||
// validOrderTypes is the set of order type strings a submit_paper_order may use.
|
||||
// An empty value defaults to "market" at the runner.
|
||||
var validOrderTypes = map[string]bool{
|
||||
"": true,
|
||||
"market": true,
|
||||
"limit": true,
|
||||
}
|
||||
|
||||
// validPaperOrderStatuses is the closed set of expect.order_status values for the
|
||||
// dry-run check, mirroring the worker-owned PaperOrderStatus vocabulary.
|
||||
var validPaperOrderStatuses = map[string]bool{
|
||||
"": true,
|
||||
"pending": true,
|
||||
"filled": true,
|
||||
"canceled": true,
|
||||
"rejected": true,
|
||||
}
|
||||
|
||||
// validMarkets is the set of market filter strings a list_instruments request
|
||||
|
|
@ -160,6 +193,9 @@ type Expect struct {
|
|||
|
||||
// RunStatus expects a specific backtest run status (e.g. "succeeded", "failed").
|
||||
RunStatus string `yaml:"run_status"`
|
||||
// OrderStatus expects a specific paper order status after a lifecycle step
|
||||
// (e.g. "pending", "filled", "canceled", "rejected").
|
||||
OrderStatus string `yaml:"order_status"`
|
||||
// TransportStatus expects a specific transport status (e.g. "transport_error").
|
||||
TransportStatus string `yaml:"transport_status"`
|
||||
// ExitCode expects a specific runner exit code.
|
||||
|
|
@ -208,6 +244,17 @@ type Request struct {
|
|||
// example "10000000"). The currency is derived from the request market.
|
||||
StartingCash string `yaml:"starting_cash"`
|
||||
|
||||
// Paper order lifecycle fields. Side/OrderType select the order shape;
|
||||
// Quantity/LimitPrice/FillPrice are decimal strings whose currency is derived
|
||||
// from the request market like StartingCash. OrderID targets an existing order
|
||||
// for cancel/fill and supports {{steps.<id>.order.id}} interpolation.
|
||||
Side string `yaml:"side"`
|
||||
Quantity string `yaml:"quantity"`
|
||||
OrderType string `yaml:"order_type"`
|
||||
LimitPrice string `yaml:"limit_price"`
|
||||
OrderID string `yaml:"order_id"`
|
||||
FillPrice string `yaml:"fill_price"`
|
||||
|
||||
// PollingInterval configures how often the runner polls for updates.
|
||||
PollingInterval Duration `yaml:"polling_interval"`
|
||||
// PollingTimeout configures the maximum time to wait during polling.
|
||||
|
|
@ -310,6 +357,9 @@ func validateExpect(step Step) error {
|
|||
if step.Expect.RunStatus != "" && !validBacktestStatuses[step.Expect.RunStatus] {
|
||||
return fmt.Errorf("step %q: unsupported expect.run_status %q", step.ID, step.Expect.RunStatus)
|
||||
}
|
||||
if step.Expect.OrderStatus != "" && !validPaperOrderStatuses[step.Expect.OrderStatus] {
|
||||
return fmt.Errorf("step %q: unsupported expect.order_status %q", step.ID, step.Expect.OrderStatus)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -394,6 +444,45 @@ func validateRequest(step Step) error {
|
|||
if step.Request.AccountID == "" {
|
||||
return fmt.Errorf("step %q: get_paper_trading_state requires request.account_id", step.ID)
|
||||
}
|
||||
case ActionSubmitPaperOrder:
|
||||
if step.Request.AccountID == "" {
|
||||
return fmt.Errorf("step %q: submit_paper_order requires request.account_id", step.ID)
|
||||
}
|
||||
if step.Request.InstrumentID == "" {
|
||||
return fmt.Errorf("step %q: submit_paper_order requires request.instrument_id", step.ID)
|
||||
}
|
||||
if !validOrderSides[step.Request.Side] {
|
||||
return fmt.Errorf("step %q: unsupported side %q (want \"buy\" or \"sell\")", step.ID, step.Request.Side)
|
||||
}
|
||||
if strings.TrimSpace(step.Request.Quantity) == "" {
|
||||
return fmt.Errorf("step %q: submit_paper_order requires request.quantity", step.ID)
|
||||
}
|
||||
if !validOrderTypes[step.Request.OrderType] {
|
||||
return fmt.Errorf("step %q: unsupported order_type %q (want \"market\" or \"limit\")", step.ID, step.Request.OrderType)
|
||||
}
|
||||
if !validMarkets[step.Request.Market] {
|
||||
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
|
||||
}
|
||||
if step.Request.OrderType == "limit" && strings.TrimSpace(step.Request.LimitPrice) == "" {
|
||||
return fmt.Errorf("step %q: submit_paper_order with order_type limit requires request.limit_price", step.ID)
|
||||
}
|
||||
case ActionCancelPaperOrder:
|
||||
if step.Request.AccountID == "" {
|
||||
return fmt.Errorf("step %q: cancel_paper_order requires request.account_id", step.ID)
|
||||
}
|
||||
if strings.TrimSpace(step.Request.OrderID) == "" {
|
||||
return fmt.Errorf("step %q: cancel_paper_order requires request.order_id", step.ID)
|
||||
}
|
||||
case ActionFillPaperOrder:
|
||||
if step.Request.AccountID == "" {
|
||||
return fmt.Errorf("step %q: fill_paper_order requires request.account_id", step.ID)
|
||||
}
|
||||
if strings.TrimSpace(step.Request.OrderID) == "" {
|
||||
return fmt.Errorf("step %q: fill_paper_order requires request.order_id", step.ID)
|
||||
}
|
||||
if step.Request.FillPrice != "" && !validMarkets[step.Request.Market] {
|
||||
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
|
||||
}
|
||||
case ActionImportDailyBars:
|
||||
if step.Request.Provider == "" {
|
||||
return fmt.Errorf("step %q: import_daily_bars requires request.provider", step.ID)
|
||||
|
|
|
|||
8
apps/cli/testdata/operator/expected/paper_order_lifecycle.jsonl
vendored
Normal file
8
apps/cli/testdata/operator/expected/paper_order_lifecycle.jsonl
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{"action":"import_daily_bars","bar_count":2,"instrument_count":1,"provider":"kis","scenario":"paper_order_lifecycle","status":"ok","step":"import_kr_daily_bars","type":"step"}
|
||||
{"account_id":"paper-1","action":"start_paper_trading","cash":"9998950","equity_point_count":2,"fill_count":1,"latest_equity":"10000100","position_count":1,"risk":"clear","run_id":"paper-paper-1-1","run_status":"succeeded","scenario":"paper_order_lifecycle","status":"ok","step":"start","type":"step"}
|
||||
{"action":"submit_paper_order","order_id":"paper-order-paper-1-1","order_status":"pending","scenario":"paper_order_lifecycle","status":"ok","step":"submit","type":"step"}
|
||||
{"action":"fill_paper_order","fill_count":1,"fill_price":"1000","order_id":"paper-order-paper-1-1","order_status":"filled","scenario":"paper_order_lifecycle","status":"ok","step":"fill","type":"step"}
|
||||
{"action":"submit_paper_order","order_id":"paper-order-paper-1-2","order_status":"pending","scenario":"paper_order_lifecycle","status":"ok","step":"submit2","type":"step"}
|
||||
{"action":"cancel_paper_order","order_id":"paper-order-paper-1-2","order_status":"canceled","scenario":"paper_order_lifecycle","status":"ok","step":"cancel","type":"step"}
|
||||
{"account_id":"paper-1","action":"get_paper_trading_state","cash":"9997950","equity_point_count":2,"fill_count":2,"latest_equity":"10000100","position_count":1,"risk":"clear","run_id":"paper-paper-1-1","run_status":"succeeded","scenario":"paper_order_lifecycle","status":"ok","step":"state","type":"step"}
|
||||
{"exit_code":0,"passed":7,"scenario":"paper_order_lifecycle","status":"ok","steps":7,"type":"summary"}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{"action":"import_daily_bars","bar_count":2,"instrument_count":1,"provider":"kis","scenario":"paper_trading_state","status":"ok","step":"import_kr_daily_bars","type":"step"}
|
||||
{"account_id":"paper-1","action":"start_paper_trading","cash":"9998950","equity_point_count":2,"fill_count":1,"latest_equity":"10000100","position_count":1,"risk":"not_evaluated","run_id":"paper-paper-1-1","run_status":"succeeded","scenario":"paper_trading_state","status":"ok","step":"start","type":"step"}
|
||||
{"account_id":"paper-1","action":"get_paper_trading_state","cash":"9998950","equity_point_count":2,"fill_count":1,"latest_equity":"10000100","position_count":1,"risk":"not_evaluated","run_id":"paper-paper-1-1","run_status":"succeeded","scenario":"paper_trading_state","status":"ok","step":"state","type":"step"}
|
||||
{"account_id":"paper-1","action":"start_paper_trading","cash":"9998950","equity_point_count":2,"fill_count":1,"latest_equity":"10000100","position_count":1,"risk":"clear","run_id":"paper-paper-1-1","run_status":"succeeded","scenario":"paper_trading_state","status":"ok","step":"start","type":"step"}
|
||||
{"account_id":"paper-1","action":"get_paper_trading_state","cash":"9998950","equity_point_count":2,"fill_count":1,"latest_equity":"10000100","position_count":1,"risk":"clear","run_id":"paper-paper-1-1","run_status":"succeeded","scenario":"paper_trading_state","status":"ok","step":"state","type":"step"}
|
||||
{"exit_code":0,"passed":3,"scenario":"paper_trading_state","status":"ok","steps":3,"type":"summary"}
|
||||
|
|
|
|||
76
apps/cli/testdata/operator/paper_order_lifecycle.yaml
vendored
Normal file
76
apps/cli/testdata/operator/paper_order_lifecycle.yaml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
name: paper_order_lifecycle
|
||||
timeout: 5s
|
||||
steps:
|
||||
- id: import_kr_daily_bars
|
||||
action: import_daily_bars
|
||||
request:
|
||||
provider: kis
|
||||
selector_kind: watchlist
|
||||
market: kr
|
||||
venue: krx
|
||||
symbols: ["005930"]
|
||||
from_yyyymmdd: "20240527"
|
||||
to_yyyymmdd: "20240528"
|
||||
expect:
|
||||
status: ok
|
||||
- id: start
|
||||
action: start_paper_trading
|
||||
request:
|
||||
account_id: paper-1
|
||||
strategy_id: strategy-v1
|
||||
market: kr
|
||||
timeframe: daily
|
||||
from_unix_ms: 1746057600000
|
||||
to_unix_ms: 1747267200000
|
||||
starting_cash: "10000000"
|
||||
expect:
|
||||
status: ok
|
||||
run_status: succeeded
|
||||
- id: submit
|
||||
action: submit_paper_order
|
||||
request:
|
||||
account_id: paper-1
|
||||
instrument_id: KRX:005930
|
||||
side: buy
|
||||
quantity: "1"
|
||||
order_type: market
|
||||
market: kr
|
||||
expect:
|
||||
status: ok
|
||||
order_status: pending
|
||||
- id: fill
|
||||
action: fill_paper_order
|
||||
request:
|
||||
account_id: paper-1
|
||||
order_id: "{{steps.submit.order.id}}"
|
||||
market: kr
|
||||
fill_price: "1000"
|
||||
expect:
|
||||
status: ok
|
||||
order_status: filled
|
||||
- id: submit2
|
||||
action: submit_paper_order
|
||||
request:
|
||||
account_id: paper-1
|
||||
instrument_id: KRX:005930
|
||||
side: buy
|
||||
quantity: "1"
|
||||
order_type: market
|
||||
market: kr
|
||||
expect:
|
||||
status: ok
|
||||
order_status: pending
|
||||
- id: cancel
|
||||
action: cancel_paper_order
|
||||
request:
|
||||
account_id: paper-1
|
||||
order_id: "{{steps.submit2.order.id}}"
|
||||
expect:
|
||||
status: ok
|
||||
order_status: canceled
|
||||
- id: state
|
||||
action: get_paper_trading_state
|
||||
request:
|
||||
account_id: paper-1
|
||||
expect:
|
||||
status: ok
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
import 'dart:core' as $core;
|
||||
|
||||
import 'package:fixnum/fixnum.dart' as $fixnum;
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
|
||||
import 'backtest.pb.dart' as $0;
|
||||
|
|
@ -318,6 +319,7 @@ class PaperTradingState extends $pb.GeneratedMessage {
|
|||
$core.Iterable<$0.BacktestPosition>? positions,
|
||||
$core.Iterable<$0.BacktestTrade>? fills,
|
||||
$core.Iterable<$0.BacktestEquityPoint>? equityCurve,
|
||||
$core.Iterable<PaperRiskRejection>? riskRejections,
|
||||
}) {
|
||||
final result = create();
|
||||
if (accountId != null) result.accountId = accountId;
|
||||
|
|
@ -326,6 +328,7 @@ class PaperTradingState extends $pb.GeneratedMessage {
|
|||
if (positions != null) result.positions.addAll(positions);
|
||||
if (fills != null) result.fills.addAll(fills);
|
||||
if (equityCurve != null) result.equityCurve.addAll(equityCurve);
|
||||
if (riskRejections != null) result.riskRejections.addAll(riskRejections);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -353,6 +356,8 @@ class PaperTradingState extends $pb.GeneratedMessage {
|
|||
subBuilder: $0.BacktestTrade.create)
|
||||
..pPM<$0.BacktestEquityPoint>(6, _omitFieldNames ? '' : 'equityCurve',
|
||||
subBuilder: $0.BacktestEquityPoint.create)
|
||||
..pPM<PaperRiskRejection>(7, _omitFieldNames ? '' : 'riskRejections',
|
||||
subBuilder: PaperRiskRejection.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
|
|
@ -413,6 +418,837 @@ class PaperTradingState extends $pb.GeneratedMessage {
|
|||
|
||||
@$pb.TagNumber(6)
|
||||
$pb.PbList<$0.BacktestEquityPoint> get equityCurve => $_getList(5);
|
||||
|
||||
/// risk_rejections lists orders the paper run's risk gate or portfolio refused.
|
||||
/// It is additive: an empty list means every decided order cleared risk, while a
|
||||
/// non-empty list lets the operator surface a blocked risk summary without faking
|
||||
/// broker behaviour. The fill path stays unchanged; this only exposes rejections
|
||||
/// the engine already tracked internally.
|
||||
@$pb.TagNumber(7)
|
||||
$pb.PbList<PaperRiskRejection> get riskRejections => $_getList(6);
|
||||
}
|
||||
|
||||
/// PaperRiskRejection summarises a single order that the paper run did not fill
|
||||
/// because a risk decision or portfolio application denied it. It mirrors the
|
||||
/// worker-owned RejectedOrder so operator surfaces can distinguish clear vs
|
||||
/// blocked risk without inspecting raw engine state.
|
||||
class PaperRiskRejection extends $pb.GeneratedMessage {
|
||||
factory PaperRiskRejection({
|
||||
$core.String? instrumentId,
|
||||
$core.String? side,
|
||||
$1.Quantity? quantity,
|
||||
$core.String? reason,
|
||||
$fixnum.Int64? timestampUnixMs,
|
||||
}) {
|
||||
final result = create();
|
||||
if (instrumentId != null) result.instrumentId = instrumentId;
|
||||
if (side != null) result.side = side;
|
||||
if (quantity != null) result.quantity = quantity;
|
||||
if (reason != null) result.reason = reason;
|
||||
if (timestampUnixMs != null) result.timestampUnixMs = timestampUnixMs;
|
||||
return result;
|
||||
}
|
||||
|
||||
PaperRiskRejection._();
|
||||
|
||||
factory PaperRiskRejection.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory PaperRiskRejection.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'PaperRiskRejection',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'instrumentId')
|
||||
..aOS(2, _omitFieldNames ? '' : 'side')
|
||||
..aOM<$1.Quantity>(3, _omitFieldNames ? '' : 'quantity',
|
||||
subBuilder: $1.Quantity.create)
|
||||
..aOS(4, _omitFieldNames ? '' : 'reason')
|
||||
..aInt64(5, _omitFieldNames ? '' : 'timestampUnixMs')
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
PaperRiskRejection clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
PaperRiskRejection copyWith(void Function(PaperRiskRejection) updates) =>
|
||||
super.copyWith((message) => updates(message as PaperRiskRejection))
|
||||
as PaperRiskRejection;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PaperRiskRejection create() => PaperRiskRejection._();
|
||||
@$core.override
|
||||
PaperRiskRejection createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PaperRiskRejection getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<PaperRiskRejection>(create);
|
||||
static PaperRiskRejection? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get instrumentId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set instrumentId($core.String value) => $_setString(0, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasInstrumentId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearInstrumentId() => $_clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get side => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set side($core.String value) => $_setString(1, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasSide() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearSide() => $_clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$1.Quantity get quantity => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set quantity($1.Quantity value) => $_setField(3, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasQuantity() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearQuantity() => $_clearField(3);
|
||||
@$pb.TagNumber(3)
|
||||
$1.Quantity ensureQuantity() => $_ensure(2);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$core.String get reason => $_getSZ(3);
|
||||
@$pb.TagNumber(4)
|
||||
set reason($core.String value) => $_setString(3, value);
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasReason() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearReason() => $_clearField(4);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$fixnum.Int64 get timestampUnixMs => $_getI64(4);
|
||||
@$pb.TagNumber(5)
|
||||
set timestampUnixMs($fixnum.Int64 value) => $_setInt64(4, value);
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasTimestampUnixMs() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearTimestampUnixMs() => $_clearField(5);
|
||||
}
|
||||
|
||||
/// PaperOrder is an operator-submitted virtual order tracked against a started
|
||||
/// paper account. Side/type/status stay string-typed to match the existing paper
|
||||
/// summary shapes (PaperRiskRejection.side) and to keep the lifecycle additive
|
||||
/// without introducing new enums. No real broker order is ever created.
|
||||
class PaperOrder extends $pb.GeneratedMessage {
|
||||
factory PaperOrder({
|
||||
$core.String? orderId,
|
||||
$core.String? accountId,
|
||||
$core.String? instrumentId,
|
||||
$core.String? side,
|
||||
$1.Quantity? quantity,
|
||||
$core.String? type,
|
||||
$1.Price? limitPrice,
|
||||
$core.String? status,
|
||||
$core.String? reason,
|
||||
$0.BacktestTrade? fill,
|
||||
$fixnum.Int64? createdAtUnixMs,
|
||||
$fixnum.Int64? updatedAtUnixMs,
|
||||
}) {
|
||||
final result = create();
|
||||
if (orderId != null) result.orderId = orderId;
|
||||
if (accountId != null) result.accountId = accountId;
|
||||
if (instrumentId != null) result.instrumentId = instrumentId;
|
||||
if (side != null) result.side = side;
|
||||
if (quantity != null) result.quantity = quantity;
|
||||
if (type != null) result.type = type;
|
||||
if (limitPrice != null) result.limitPrice = limitPrice;
|
||||
if (status != null) result.status = status;
|
||||
if (reason != null) result.reason = reason;
|
||||
if (fill != null) result.fill = fill;
|
||||
if (createdAtUnixMs != null) result.createdAtUnixMs = createdAtUnixMs;
|
||||
if (updatedAtUnixMs != null) result.updatedAtUnixMs = updatedAtUnixMs;
|
||||
return result;
|
||||
}
|
||||
|
||||
PaperOrder._();
|
||||
|
||||
factory PaperOrder.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory PaperOrder.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'PaperOrder',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'orderId')
|
||||
..aOS(2, _omitFieldNames ? '' : 'accountId')
|
||||
..aOS(3, _omitFieldNames ? '' : 'instrumentId')
|
||||
..aOS(4, _omitFieldNames ? '' : 'side')
|
||||
..aOM<$1.Quantity>(5, _omitFieldNames ? '' : 'quantity',
|
||||
subBuilder: $1.Quantity.create)
|
||||
..aOS(6, _omitFieldNames ? '' : 'type')
|
||||
..aOM<$1.Price>(7, _omitFieldNames ? '' : 'limitPrice',
|
||||
subBuilder: $1.Price.create)
|
||||
..aOS(8, _omitFieldNames ? '' : 'status')
|
||||
..aOS(9, _omitFieldNames ? '' : 'reason')
|
||||
..aOM<$0.BacktestTrade>(10, _omitFieldNames ? '' : 'fill',
|
||||
subBuilder: $0.BacktestTrade.create)
|
||||
..aInt64(11, _omitFieldNames ? '' : 'createdAtUnixMs')
|
||||
..aInt64(12, _omitFieldNames ? '' : 'updatedAtUnixMs')
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
PaperOrder clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
PaperOrder copyWith(void Function(PaperOrder) updates) =>
|
||||
super.copyWith((message) => updates(message as PaperOrder)) as PaperOrder;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PaperOrder create() => PaperOrder._();
|
||||
@$core.override
|
||||
PaperOrder createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static PaperOrder getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<PaperOrder>(create);
|
||||
static PaperOrder? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get orderId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set orderId($core.String value) => $_setString(0, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasOrderId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearOrderId() => $_clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get accountId => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set accountId($core.String value) => $_setString(1, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasAccountId() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearAccountId() => $_clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get instrumentId => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set instrumentId($core.String value) => $_setString(2, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasInstrumentId() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearInstrumentId() => $_clearField(3);
|
||||
|
||||
/// side is "buy" or "sell".
|
||||
@$pb.TagNumber(4)
|
||||
$core.String get side => $_getSZ(3);
|
||||
@$pb.TagNumber(4)
|
||||
set side($core.String value) => $_setString(3, value);
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasSide() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearSide() => $_clearField(4);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$1.Quantity get quantity => $_getN(4);
|
||||
@$pb.TagNumber(5)
|
||||
set quantity($1.Quantity value) => $_setField(5, value);
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasQuantity() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearQuantity() => $_clearField(5);
|
||||
@$pb.TagNumber(5)
|
||||
$1.Quantity ensureQuantity() => $_ensure(4);
|
||||
|
||||
/// type is "market" or "limit".
|
||||
@$pb.TagNumber(6)
|
||||
$core.String get type => $_getSZ(5);
|
||||
@$pb.TagNumber(6)
|
||||
set type($core.String value) => $_setString(5, value);
|
||||
@$pb.TagNumber(6)
|
||||
$core.bool hasType() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearType() => $_clearField(6);
|
||||
|
||||
/// limit_price is only set for a limit order.
|
||||
@$pb.TagNumber(7)
|
||||
$1.Price get limitPrice => $_getN(6);
|
||||
@$pb.TagNumber(7)
|
||||
set limitPrice($1.Price value) => $_setField(7, value);
|
||||
@$pb.TagNumber(7)
|
||||
$core.bool hasLimitPrice() => $_has(6);
|
||||
@$pb.TagNumber(7)
|
||||
void clearLimitPrice() => $_clearField(7);
|
||||
@$pb.TagNumber(7)
|
||||
$1.Price ensureLimitPrice() => $_ensure(6);
|
||||
|
||||
/// status is "pending", "filled", "canceled", or "rejected".
|
||||
@$pb.TagNumber(8)
|
||||
$core.String get status => $_getSZ(7);
|
||||
@$pb.TagNumber(8)
|
||||
set status($core.String value) => $_setString(7, value);
|
||||
@$pb.TagNumber(8)
|
||||
$core.bool hasStatus() => $_has(7);
|
||||
@$pb.TagNumber(8)
|
||||
void clearStatus() => $_clearField(8);
|
||||
|
||||
/// reason is populated when status is "rejected" (risk or portfolio denial).
|
||||
@$pb.TagNumber(9)
|
||||
$core.String get reason => $_getSZ(8);
|
||||
@$pb.TagNumber(9)
|
||||
set reason($core.String value) => $_setString(8, value);
|
||||
@$pb.TagNumber(9)
|
||||
$core.bool hasReason() => $_has(8);
|
||||
@$pb.TagNumber(9)
|
||||
void clearReason() => $_clearField(9);
|
||||
|
||||
/// fill is populated when status is "filled"; it reuses the BacktestTrade shape
|
||||
/// so a fill summary carries instrument/side/quantity/price/timestamp.
|
||||
@$pb.TagNumber(10)
|
||||
$0.BacktestTrade get fill => $_getN(9);
|
||||
@$pb.TagNumber(10)
|
||||
set fill($0.BacktestTrade value) => $_setField(10, value);
|
||||
@$pb.TagNumber(10)
|
||||
$core.bool hasFill() => $_has(9);
|
||||
@$pb.TagNumber(10)
|
||||
void clearFill() => $_clearField(10);
|
||||
@$pb.TagNumber(10)
|
||||
$0.BacktestTrade ensureFill() => $_ensure(9);
|
||||
|
||||
@$pb.TagNumber(11)
|
||||
$fixnum.Int64 get createdAtUnixMs => $_getI64(10);
|
||||
@$pb.TagNumber(11)
|
||||
set createdAtUnixMs($fixnum.Int64 value) => $_setInt64(10, value);
|
||||
@$pb.TagNumber(11)
|
||||
$core.bool hasCreatedAtUnixMs() => $_has(10);
|
||||
@$pb.TagNumber(11)
|
||||
void clearCreatedAtUnixMs() => $_clearField(11);
|
||||
|
||||
@$pb.TagNumber(12)
|
||||
$fixnum.Int64 get updatedAtUnixMs => $_getI64(11);
|
||||
@$pb.TagNumber(12)
|
||||
set updatedAtUnixMs($fixnum.Int64 value) => $_setInt64(11, value);
|
||||
@$pb.TagNumber(12)
|
||||
$core.bool hasUpdatedAtUnixMs() => $_has(11);
|
||||
@$pb.TagNumber(12)
|
||||
void clearUpdatedAtUnixMs() => $_clearField(12);
|
||||
}
|
||||
|
||||
class SubmitPaperOrderRequest extends $pb.GeneratedMessage {
|
||||
factory SubmitPaperOrderRequest({
|
||||
$core.String? accountId,
|
||||
$core.String? instrumentId,
|
||||
$core.String? side,
|
||||
$1.Quantity? quantity,
|
||||
$core.String? type,
|
||||
$1.Price? limitPrice,
|
||||
}) {
|
||||
final result = create();
|
||||
if (accountId != null) result.accountId = accountId;
|
||||
if (instrumentId != null) result.instrumentId = instrumentId;
|
||||
if (side != null) result.side = side;
|
||||
if (quantity != null) result.quantity = quantity;
|
||||
if (type != null) result.type = type;
|
||||
if (limitPrice != null) result.limitPrice = limitPrice;
|
||||
return result;
|
||||
}
|
||||
|
||||
SubmitPaperOrderRequest._();
|
||||
|
||||
factory SubmitPaperOrderRequest.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory SubmitPaperOrderRequest.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'SubmitPaperOrderRequest',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'accountId')
|
||||
..aOS(2, _omitFieldNames ? '' : 'instrumentId')
|
||||
..aOS(3, _omitFieldNames ? '' : 'side')
|
||||
..aOM<$1.Quantity>(4, _omitFieldNames ? '' : 'quantity',
|
||||
subBuilder: $1.Quantity.create)
|
||||
..aOS(5, _omitFieldNames ? '' : 'type')
|
||||
..aOM<$1.Price>(6, _omitFieldNames ? '' : 'limitPrice',
|
||||
subBuilder: $1.Price.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
SubmitPaperOrderRequest clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
SubmitPaperOrderRequest copyWith(
|
||||
void Function(SubmitPaperOrderRequest) updates) =>
|
||||
super.copyWith((message) => updates(message as SubmitPaperOrderRequest))
|
||||
as SubmitPaperOrderRequest;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SubmitPaperOrderRequest create() => SubmitPaperOrderRequest._();
|
||||
@$core.override
|
||||
SubmitPaperOrderRequest createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SubmitPaperOrderRequest getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<SubmitPaperOrderRequest>(create);
|
||||
static SubmitPaperOrderRequest? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get accountId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set accountId($core.String value) => $_setString(0, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasAccountId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearAccountId() => $_clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get instrumentId => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set instrumentId($core.String value) => $_setString(1, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasInstrumentId() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearInstrumentId() => $_clearField(2);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$core.String get side => $_getSZ(2);
|
||||
@$pb.TagNumber(3)
|
||||
set side($core.String value) => $_setString(2, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasSide() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearSide() => $_clearField(3);
|
||||
|
||||
@$pb.TagNumber(4)
|
||||
$1.Quantity get quantity => $_getN(3);
|
||||
@$pb.TagNumber(4)
|
||||
set quantity($1.Quantity value) => $_setField(4, value);
|
||||
@$pb.TagNumber(4)
|
||||
$core.bool hasQuantity() => $_has(3);
|
||||
@$pb.TagNumber(4)
|
||||
void clearQuantity() => $_clearField(4);
|
||||
@$pb.TagNumber(4)
|
||||
$1.Quantity ensureQuantity() => $_ensure(3);
|
||||
|
||||
@$pb.TagNumber(5)
|
||||
$core.String get type => $_getSZ(4);
|
||||
@$pb.TagNumber(5)
|
||||
set type($core.String value) => $_setString(4, value);
|
||||
@$pb.TagNumber(5)
|
||||
$core.bool hasType() => $_has(4);
|
||||
@$pb.TagNumber(5)
|
||||
void clearType() => $_clearField(5);
|
||||
|
||||
@$pb.TagNumber(6)
|
||||
$1.Price get limitPrice => $_getN(5);
|
||||
@$pb.TagNumber(6)
|
||||
set limitPrice($1.Price value) => $_setField(6, value);
|
||||
@$pb.TagNumber(6)
|
||||
$core.bool hasLimitPrice() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearLimitPrice() => $_clearField(6);
|
||||
@$pb.TagNumber(6)
|
||||
$1.Price ensureLimitPrice() => $_ensure(5);
|
||||
}
|
||||
|
||||
class SubmitPaperOrderResponse extends $pb.GeneratedMessage {
|
||||
factory SubmitPaperOrderResponse({
|
||||
PaperOrder? order,
|
||||
$2.ErrorInfo? error,
|
||||
}) {
|
||||
final result = create();
|
||||
if (order != null) result.order = order;
|
||||
if (error != null) result.error = error;
|
||||
return result;
|
||||
}
|
||||
|
||||
SubmitPaperOrderResponse._();
|
||||
|
||||
factory SubmitPaperOrderResponse.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory SubmitPaperOrderResponse.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'SubmitPaperOrderResponse',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOM<PaperOrder>(1, _omitFieldNames ? '' : 'order',
|
||||
subBuilder: PaperOrder.create)
|
||||
..aOM<$2.ErrorInfo>(2, _omitFieldNames ? '' : 'error',
|
||||
subBuilder: $2.ErrorInfo.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
SubmitPaperOrderResponse clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
SubmitPaperOrderResponse copyWith(
|
||||
void Function(SubmitPaperOrderResponse) updates) =>
|
||||
super.copyWith((message) => updates(message as SubmitPaperOrderResponse))
|
||||
as SubmitPaperOrderResponse;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SubmitPaperOrderResponse create() => SubmitPaperOrderResponse._();
|
||||
@$core.override
|
||||
SubmitPaperOrderResponse createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static SubmitPaperOrderResponse getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<SubmitPaperOrderResponse>(create);
|
||||
static SubmitPaperOrderResponse? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
PaperOrder get order => $_getN(0);
|
||||
@$pb.TagNumber(1)
|
||||
set order(PaperOrder value) => $_setField(1, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasOrder() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearOrder() => $_clearField(1);
|
||||
@$pb.TagNumber(1)
|
||||
PaperOrder ensureOrder() => $_ensure(0);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$2.ErrorInfo get error => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set error($2.ErrorInfo value) => $_setField(2, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasError() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearError() => $_clearField(2);
|
||||
@$pb.TagNumber(2)
|
||||
$2.ErrorInfo ensureError() => $_ensure(1);
|
||||
}
|
||||
|
||||
class CancelPaperOrderRequest extends $pb.GeneratedMessage {
|
||||
factory CancelPaperOrderRequest({
|
||||
$core.String? accountId,
|
||||
$core.String? orderId,
|
||||
}) {
|
||||
final result = create();
|
||||
if (accountId != null) result.accountId = accountId;
|
||||
if (orderId != null) result.orderId = orderId;
|
||||
return result;
|
||||
}
|
||||
|
||||
CancelPaperOrderRequest._();
|
||||
|
||||
factory CancelPaperOrderRequest.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory CancelPaperOrderRequest.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'CancelPaperOrderRequest',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'accountId')
|
||||
..aOS(2, _omitFieldNames ? '' : 'orderId')
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
CancelPaperOrderRequest clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
CancelPaperOrderRequest copyWith(
|
||||
void Function(CancelPaperOrderRequest) updates) =>
|
||||
super.copyWith((message) => updates(message as CancelPaperOrderRequest))
|
||||
as CancelPaperOrderRequest;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static CancelPaperOrderRequest create() => CancelPaperOrderRequest._();
|
||||
@$core.override
|
||||
CancelPaperOrderRequest createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static CancelPaperOrderRequest getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<CancelPaperOrderRequest>(create);
|
||||
static CancelPaperOrderRequest? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get accountId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set accountId($core.String value) => $_setString(0, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasAccountId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearAccountId() => $_clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get orderId => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set orderId($core.String value) => $_setString(1, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasOrderId() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearOrderId() => $_clearField(2);
|
||||
}
|
||||
|
||||
class CancelPaperOrderResponse extends $pb.GeneratedMessage {
|
||||
factory CancelPaperOrderResponse({
|
||||
PaperOrder? order,
|
||||
$2.ErrorInfo? error,
|
||||
}) {
|
||||
final result = create();
|
||||
if (order != null) result.order = order;
|
||||
if (error != null) result.error = error;
|
||||
return result;
|
||||
}
|
||||
|
||||
CancelPaperOrderResponse._();
|
||||
|
||||
factory CancelPaperOrderResponse.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory CancelPaperOrderResponse.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'CancelPaperOrderResponse',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOM<PaperOrder>(1, _omitFieldNames ? '' : 'order',
|
||||
subBuilder: PaperOrder.create)
|
||||
..aOM<$2.ErrorInfo>(2, _omitFieldNames ? '' : 'error',
|
||||
subBuilder: $2.ErrorInfo.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
CancelPaperOrderResponse clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
CancelPaperOrderResponse copyWith(
|
||||
void Function(CancelPaperOrderResponse) updates) =>
|
||||
super.copyWith((message) => updates(message as CancelPaperOrderResponse))
|
||||
as CancelPaperOrderResponse;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static CancelPaperOrderResponse create() => CancelPaperOrderResponse._();
|
||||
@$core.override
|
||||
CancelPaperOrderResponse createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static CancelPaperOrderResponse getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<CancelPaperOrderResponse>(create);
|
||||
static CancelPaperOrderResponse? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
PaperOrder get order => $_getN(0);
|
||||
@$pb.TagNumber(1)
|
||||
set order(PaperOrder value) => $_setField(1, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasOrder() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearOrder() => $_clearField(1);
|
||||
@$pb.TagNumber(1)
|
||||
PaperOrder ensureOrder() => $_ensure(0);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$2.ErrorInfo get error => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set error($2.ErrorInfo value) => $_setField(2, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasError() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearError() => $_clearField(2);
|
||||
@$pb.TagNumber(2)
|
||||
$2.ErrorInfo ensureError() => $_ensure(1);
|
||||
}
|
||||
|
||||
class FillPaperOrderRequest extends $pb.GeneratedMessage {
|
||||
factory FillPaperOrderRequest({
|
||||
$core.String? accountId,
|
||||
$core.String? orderId,
|
||||
$1.Price? fillPrice,
|
||||
}) {
|
||||
final result = create();
|
||||
if (accountId != null) result.accountId = accountId;
|
||||
if (orderId != null) result.orderId = orderId;
|
||||
if (fillPrice != null) result.fillPrice = fillPrice;
|
||||
return result;
|
||||
}
|
||||
|
||||
FillPaperOrderRequest._();
|
||||
|
||||
factory FillPaperOrderRequest.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory FillPaperOrderRequest.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'FillPaperOrderRequest',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOS(1, _omitFieldNames ? '' : 'accountId')
|
||||
..aOS(2, _omitFieldNames ? '' : 'orderId')
|
||||
..aOM<$1.Price>(3, _omitFieldNames ? '' : 'fillPrice',
|
||||
subBuilder: $1.Price.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
FillPaperOrderRequest clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
FillPaperOrderRequest copyWith(
|
||||
void Function(FillPaperOrderRequest) updates) =>
|
||||
super.copyWith((message) => updates(message as FillPaperOrderRequest))
|
||||
as FillPaperOrderRequest;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static FillPaperOrderRequest create() => FillPaperOrderRequest._();
|
||||
@$core.override
|
||||
FillPaperOrderRequest createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static FillPaperOrderRequest getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<FillPaperOrderRequest>(create);
|
||||
static FillPaperOrderRequest? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.String get accountId => $_getSZ(0);
|
||||
@$pb.TagNumber(1)
|
||||
set accountId($core.String value) => $_setString(0, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasAccountId() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearAccountId() => $_clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
$core.String get orderId => $_getSZ(1);
|
||||
@$pb.TagNumber(2)
|
||||
set orderId($core.String value) => $_setString(1, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasOrderId() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearOrderId() => $_clearField(2);
|
||||
|
||||
/// fill_price is the simulated execution price for a market order. A limit order
|
||||
/// executes at its own limit price, so fill_price may be omitted for it.
|
||||
@$pb.TagNumber(3)
|
||||
$1.Price get fillPrice => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set fillPrice($1.Price value) => $_setField(3, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasFillPrice() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearFillPrice() => $_clearField(3);
|
||||
@$pb.TagNumber(3)
|
||||
$1.Price ensureFillPrice() => $_ensure(2);
|
||||
}
|
||||
|
||||
class FillPaperOrderResponse extends $pb.GeneratedMessage {
|
||||
factory FillPaperOrderResponse({
|
||||
PaperOrder? order,
|
||||
PaperTradingState? state,
|
||||
$2.ErrorInfo? error,
|
||||
}) {
|
||||
final result = create();
|
||||
if (order != null) result.order = order;
|
||||
if (state != null) result.state = state;
|
||||
if (error != null) result.error = error;
|
||||
return result;
|
||||
}
|
||||
|
||||
FillPaperOrderResponse._();
|
||||
|
||||
factory FillPaperOrderResponse.fromBuffer($core.List<$core.int> data,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromBuffer(data, registry);
|
||||
factory FillPaperOrderResponse.fromJson($core.String json,
|
||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||
create()..mergeFromJson(json, registry);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||
_omitMessageNames ? '' : 'FillPaperOrderResponse',
|
||||
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
|
||||
createEmptyInstance: create)
|
||||
..aOM<PaperOrder>(1, _omitFieldNames ? '' : 'order',
|
||||
subBuilder: PaperOrder.create)
|
||||
..aOM<PaperTradingState>(2, _omitFieldNames ? '' : 'state',
|
||||
subBuilder: PaperTradingState.create)
|
||||
..aOM<$2.ErrorInfo>(3, _omitFieldNames ? '' : 'error',
|
||||
subBuilder: $2.ErrorInfo.create)
|
||||
..hasRequiredFields = false;
|
||||
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
FillPaperOrderResponse clone() => deepCopy();
|
||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||
FillPaperOrderResponse copyWith(
|
||||
void Function(FillPaperOrderResponse) updates) =>
|
||||
super.copyWith((message) => updates(message as FillPaperOrderResponse))
|
||||
as FillPaperOrderResponse;
|
||||
|
||||
@$core.override
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static FillPaperOrderResponse create() => FillPaperOrderResponse._();
|
||||
@$core.override
|
||||
FillPaperOrderResponse createEmptyInstance() => create();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static FillPaperOrderResponse getDefault() => _defaultInstance ??=
|
||||
$pb.GeneratedMessage.$_defaultFor<FillPaperOrderResponse>(create);
|
||||
static FillPaperOrderResponse? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
PaperOrder get order => $_getN(0);
|
||||
@$pb.TagNumber(1)
|
||||
set order(PaperOrder value) => $_setField(1, value);
|
||||
@$pb.TagNumber(1)
|
||||
$core.bool hasOrder() => $_has(0);
|
||||
@$pb.TagNumber(1)
|
||||
void clearOrder() => $_clearField(1);
|
||||
@$pb.TagNumber(1)
|
||||
PaperOrder ensureOrder() => $_ensure(0);
|
||||
|
||||
/// state is the account snapshot after the fill so the operator sees the cash
|
||||
/// and position effect without a second query. For a rejected order it carries
|
||||
/// the unchanged account state.
|
||||
@$pb.TagNumber(2)
|
||||
PaperTradingState get state => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set state(PaperTradingState value) => $_setField(2, value);
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasState() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearState() => $_clearField(2);
|
||||
@$pb.TagNumber(2)
|
||||
PaperTradingState ensureState() => $_ensure(1);
|
||||
|
||||
@$pb.TagNumber(3)
|
||||
$2.ErrorInfo get error => $_getN(2);
|
||||
@$pb.TagNumber(3)
|
||||
set error($2.ErrorInfo value) => $_setField(3, value);
|
||||
@$pb.TagNumber(3)
|
||||
$core.bool hasError() => $_has(2);
|
||||
@$pb.TagNumber(3)
|
||||
void clearError() => $_clearField(3);
|
||||
@$pb.TagNumber(3)
|
||||
$2.ErrorInfo ensureError() => $_ensure(2);
|
||||
}
|
||||
|
||||
const $core.bool _omitFieldNames =
|
||||
|
|
|
|||
|
|
@ -156,6 +156,14 @@ const PaperTradingState$json = {
|
|||
'6': '.alt.v1.BacktestEquityPoint',
|
||||
'10': 'equityCurve'
|
||||
},
|
||||
{
|
||||
'1': 'risk_rejections',
|
||||
'3': 7,
|
||||
'4': 3,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.PaperRiskRejection',
|
||||
'10': 'riskRejections'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -166,4 +174,260 @@ final $typed_data.Uint8List paperTradingStateDescriptor = $convert.base64Decode(
|
|||
'MS5QcmljZVIEY2FzaBI2Cglwb3NpdGlvbnMYBCADKAsyGC5hbHQudjEuQmFja3Rlc3RQb3NpdG'
|
||||
'lvblIJcG9zaXRpb25zEisKBWZpbGxzGAUgAygLMhUuYWx0LnYxLkJhY2t0ZXN0VHJhZGVSBWZp'
|
||||
'bGxzEj4KDGVxdWl0eV9jdXJ2ZRgGIAMoCzIbLmFsdC52MS5CYWNrdGVzdEVxdWl0eVBvaW50Ug'
|
||||
'tlcXVpdHlDdXJ2ZQ==');
|
||||
'tlcXVpdHlDdXJ2ZRJDCg9yaXNrX3JlamVjdGlvbnMYByADKAsyGi5hbHQudjEuUGFwZXJSaXNr'
|
||||
'UmVqZWN0aW9uUg5yaXNrUmVqZWN0aW9ucw==');
|
||||
|
||||
@$core.Deprecated('Use paperRiskRejectionDescriptor instead')
|
||||
const PaperRiskRejection$json = {
|
||||
'1': 'PaperRiskRejection',
|
||||
'2': [
|
||||
{'1': 'instrument_id', '3': 1, '4': 1, '5': 9, '10': 'instrumentId'},
|
||||
{'1': 'side', '3': 2, '4': 1, '5': 9, '10': 'side'},
|
||||
{
|
||||
'1': 'quantity',
|
||||
'3': 3,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.Quantity',
|
||||
'10': 'quantity'
|
||||
},
|
||||
{'1': 'reason', '3': 4, '4': 1, '5': 9, '10': 'reason'},
|
||||
{'1': 'timestamp_unix_ms', '3': 5, '4': 1, '5': 3, '10': 'timestampUnixMs'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PaperRiskRejection`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List paperRiskRejectionDescriptor = $convert.base64Decode(
|
||||
'ChJQYXBlclJpc2tSZWplY3Rpb24SIwoNaW5zdHJ1bWVudF9pZBgBIAEoCVIMaW5zdHJ1bWVudE'
|
||||
'lkEhIKBHNpZGUYAiABKAlSBHNpZGUSLAoIcXVhbnRpdHkYAyABKAsyEC5hbHQudjEuUXVhbnRp'
|
||||
'dHlSCHF1YW50aXR5EhYKBnJlYXNvbhgEIAEoCVIGcmVhc29uEioKEXRpbWVzdGFtcF91bml4X2'
|
||||
'1zGAUgASgDUg90aW1lc3RhbXBVbml4TXM=');
|
||||
|
||||
@$core.Deprecated('Use paperOrderDescriptor instead')
|
||||
const PaperOrder$json = {
|
||||
'1': 'PaperOrder',
|
||||
'2': [
|
||||
{'1': 'order_id', '3': 1, '4': 1, '5': 9, '10': 'orderId'},
|
||||
{'1': 'account_id', '3': 2, '4': 1, '5': 9, '10': 'accountId'},
|
||||
{'1': 'instrument_id', '3': 3, '4': 1, '5': 9, '10': 'instrumentId'},
|
||||
{'1': 'side', '3': 4, '4': 1, '5': 9, '10': 'side'},
|
||||
{
|
||||
'1': 'quantity',
|
||||
'3': 5,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.Quantity',
|
||||
'10': 'quantity'
|
||||
},
|
||||
{'1': 'type', '3': 6, '4': 1, '5': 9, '10': 'type'},
|
||||
{
|
||||
'1': 'limit_price',
|
||||
'3': 7,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.Price',
|
||||
'10': 'limitPrice'
|
||||
},
|
||||
{'1': 'status', '3': 8, '4': 1, '5': 9, '10': 'status'},
|
||||
{'1': 'reason', '3': 9, '4': 1, '5': 9, '10': 'reason'},
|
||||
{
|
||||
'1': 'fill',
|
||||
'3': 10,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.BacktestTrade',
|
||||
'10': 'fill'
|
||||
},
|
||||
{
|
||||
'1': 'created_at_unix_ms',
|
||||
'3': 11,
|
||||
'4': 1,
|
||||
'5': 3,
|
||||
'10': 'createdAtUnixMs'
|
||||
},
|
||||
{
|
||||
'1': 'updated_at_unix_ms',
|
||||
'3': 12,
|
||||
'4': 1,
|
||||
'5': 3,
|
||||
'10': 'updatedAtUnixMs'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `PaperOrder`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List paperOrderDescriptor = $convert.base64Decode(
|
||||
'CgpQYXBlck9yZGVyEhkKCG9yZGVyX2lkGAEgASgJUgdvcmRlcklkEh0KCmFjY291bnRfaWQYAi'
|
||||
'ABKAlSCWFjY291bnRJZBIjCg1pbnN0cnVtZW50X2lkGAMgASgJUgxpbnN0cnVtZW50SWQSEgoE'
|
||||
'c2lkZRgEIAEoCVIEc2lkZRIsCghxdWFudGl0eRgFIAEoCzIQLmFsdC52MS5RdWFudGl0eVIIcX'
|
||||
'VhbnRpdHkSEgoEdHlwZRgGIAEoCVIEdHlwZRIuCgtsaW1pdF9wcmljZRgHIAEoCzINLmFsdC52'
|
||||
'MS5QcmljZVIKbGltaXRQcmljZRIWCgZzdGF0dXMYCCABKAlSBnN0YXR1cxIWCgZyZWFzb24YCS'
|
||||
'ABKAlSBnJlYXNvbhIpCgRmaWxsGAogASgLMhUuYWx0LnYxLkJhY2t0ZXN0VHJhZGVSBGZpbGwS'
|
||||
'KwoSY3JlYXRlZF9hdF91bml4X21zGAsgASgDUg9jcmVhdGVkQXRVbml4TXMSKwoSdXBkYXRlZF'
|
||||
'9hdF91bml4X21zGAwgASgDUg91cGRhdGVkQXRVbml4TXM=');
|
||||
|
||||
@$core.Deprecated('Use submitPaperOrderRequestDescriptor instead')
|
||||
const SubmitPaperOrderRequest$json = {
|
||||
'1': 'SubmitPaperOrderRequest',
|
||||
'2': [
|
||||
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
|
||||
{'1': 'instrument_id', '3': 2, '4': 1, '5': 9, '10': 'instrumentId'},
|
||||
{'1': 'side', '3': 3, '4': 1, '5': 9, '10': 'side'},
|
||||
{
|
||||
'1': 'quantity',
|
||||
'3': 4,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.Quantity',
|
||||
'10': 'quantity'
|
||||
},
|
||||
{'1': 'type', '3': 5, '4': 1, '5': 9, '10': 'type'},
|
||||
{
|
||||
'1': 'limit_price',
|
||||
'3': 6,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.Price',
|
||||
'10': 'limitPrice'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SubmitPaperOrderRequest`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List submitPaperOrderRequestDescriptor = $convert.base64Decode(
|
||||
'ChdTdWJtaXRQYXBlck9yZGVyUmVxdWVzdBIdCgphY2NvdW50X2lkGAEgASgJUglhY2NvdW50SW'
|
||||
'QSIwoNaW5zdHJ1bWVudF9pZBgCIAEoCVIMaW5zdHJ1bWVudElkEhIKBHNpZGUYAyABKAlSBHNp'
|
||||
'ZGUSLAoIcXVhbnRpdHkYBCABKAsyEC5hbHQudjEuUXVhbnRpdHlSCHF1YW50aXR5EhIKBHR5cG'
|
||||
'UYBSABKAlSBHR5cGUSLgoLbGltaXRfcHJpY2UYBiABKAsyDS5hbHQudjEuUHJpY2VSCmxpbWl0'
|
||||
'UHJpY2U=');
|
||||
|
||||
@$core.Deprecated('Use submitPaperOrderResponseDescriptor instead')
|
||||
const SubmitPaperOrderResponse$json = {
|
||||
'1': 'SubmitPaperOrderResponse',
|
||||
'2': [
|
||||
{
|
||||
'1': 'order',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.PaperOrder',
|
||||
'10': 'order'
|
||||
},
|
||||
{
|
||||
'1': 'error',
|
||||
'3': 2,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.ErrorInfo',
|
||||
'10': 'error'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `SubmitPaperOrderResponse`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List submitPaperOrderResponseDescriptor = $convert.base64Decode(
|
||||
'ChhTdWJtaXRQYXBlck9yZGVyUmVzcG9uc2USKAoFb3JkZXIYASABKAsyEi5hbHQudjEuUGFwZX'
|
||||
'JPcmRlclIFb3JkZXISJwoFZXJyb3IYAiABKAsyES5hbHQudjEuRXJyb3JJbmZvUgVlcnJvcg==');
|
||||
|
||||
@$core.Deprecated('Use cancelPaperOrderRequestDescriptor instead')
|
||||
const CancelPaperOrderRequest$json = {
|
||||
'1': 'CancelPaperOrderRequest',
|
||||
'2': [
|
||||
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
|
||||
{'1': 'order_id', '3': 2, '4': 1, '5': 9, '10': 'orderId'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `CancelPaperOrderRequest`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List cancelPaperOrderRequestDescriptor =
|
||||
$convert.base64Decode(
|
||||
'ChdDYW5jZWxQYXBlck9yZGVyUmVxdWVzdBIdCgphY2NvdW50X2lkGAEgASgJUglhY2NvdW50SW'
|
||||
'QSGQoIb3JkZXJfaWQYAiABKAlSB29yZGVySWQ=');
|
||||
|
||||
@$core.Deprecated('Use cancelPaperOrderResponseDescriptor instead')
|
||||
const CancelPaperOrderResponse$json = {
|
||||
'1': 'CancelPaperOrderResponse',
|
||||
'2': [
|
||||
{
|
||||
'1': 'order',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.PaperOrder',
|
||||
'10': 'order'
|
||||
},
|
||||
{
|
||||
'1': 'error',
|
||||
'3': 2,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.ErrorInfo',
|
||||
'10': 'error'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `CancelPaperOrderResponse`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List cancelPaperOrderResponseDescriptor = $convert.base64Decode(
|
||||
'ChhDYW5jZWxQYXBlck9yZGVyUmVzcG9uc2USKAoFb3JkZXIYASABKAsyEi5hbHQudjEuUGFwZX'
|
||||
'JPcmRlclIFb3JkZXISJwoFZXJyb3IYAiABKAsyES5hbHQudjEuRXJyb3JJbmZvUgVlcnJvcg==');
|
||||
|
||||
@$core.Deprecated('Use fillPaperOrderRequestDescriptor instead')
|
||||
const FillPaperOrderRequest$json = {
|
||||
'1': 'FillPaperOrderRequest',
|
||||
'2': [
|
||||
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
|
||||
{'1': 'order_id', '3': 2, '4': 1, '5': 9, '10': 'orderId'},
|
||||
{
|
||||
'1': 'fill_price',
|
||||
'3': 3,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.Price',
|
||||
'10': 'fillPrice'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `FillPaperOrderRequest`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List fillPaperOrderRequestDescriptor = $convert.base64Decode(
|
||||
'ChVGaWxsUGFwZXJPcmRlclJlcXVlc3QSHQoKYWNjb3VudF9pZBgBIAEoCVIJYWNjb3VudElkEh'
|
||||
'kKCG9yZGVyX2lkGAIgASgJUgdvcmRlcklkEiwKCmZpbGxfcHJpY2UYAyABKAsyDS5hbHQudjEu'
|
||||
'UHJpY2VSCWZpbGxQcmljZQ==');
|
||||
|
||||
@$core.Deprecated('Use fillPaperOrderResponseDescriptor instead')
|
||||
const FillPaperOrderResponse$json = {
|
||||
'1': 'FillPaperOrderResponse',
|
||||
'2': [
|
||||
{
|
||||
'1': 'order',
|
||||
'3': 1,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.PaperOrder',
|
||||
'10': 'order'
|
||||
},
|
||||
{
|
||||
'1': 'state',
|
||||
'3': 2,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.PaperTradingState',
|
||||
'10': 'state'
|
||||
},
|
||||
{
|
||||
'1': 'error',
|
||||
'3': 3,
|
||||
'4': 1,
|
||||
'5': 11,
|
||||
'6': '.alt.v1.ErrorInfo',
|
||||
'10': 'error'
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `FillPaperOrderResponse`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List fillPaperOrderResponseDescriptor = $convert.base64Decode(
|
||||
'ChZGaWxsUGFwZXJPcmRlclJlc3BvbnNlEigKBW9yZGVyGAEgASgLMhIuYWx0LnYxLlBhcGVyT3'
|
||||
'JkZXJSBW9yZGVyEi8KBXN0YXRlGAIgASgLMhkuYWx0LnYxLlBhcGVyVHJhZGluZ1N0YXRlUgVz'
|
||||
'dGF0ZRInCgVlcnJvchgDIAEoCzIRLmFsdC52MS5FcnJvckluZm9SBWVycm9y');
|
||||
|
|
|
|||
|
|
@ -230,15 +230,21 @@ func (x *GetPaperTradingStateResponse) GetError() *ErrorInfo {
|
|||
}
|
||||
|
||||
type PaperTradingState struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
|
||||
Run *BacktestRun `protobuf:"bytes,2,opt,name=run,proto3" json:"run,omitempty"`
|
||||
Cash *Price `protobuf:"bytes,3,opt,name=cash,proto3" json:"cash,omitempty"`
|
||||
Positions []*BacktestPosition `protobuf:"bytes,4,rep,name=positions,proto3" json:"positions,omitempty"`
|
||||
Fills []*BacktestTrade `protobuf:"bytes,5,rep,name=fills,proto3" json:"fills,omitempty"`
|
||||
EquityCurve []*BacktestEquityPoint `protobuf:"bytes,6,rep,name=equity_curve,json=equityCurve,proto3" json:"equity_curve,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
|
||||
Run *BacktestRun `protobuf:"bytes,2,opt,name=run,proto3" json:"run,omitempty"`
|
||||
Cash *Price `protobuf:"bytes,3,opt,name=cash,proto3" json:"cash,omitempty"`
|
||||
Positions []*BacktestPosition `protobuf:"bytes,4,rep,name=positions,proto3" json:"positions,omitempty"`
|
||||
Fills []*BacktestTrade `protobuf:"bytes,5,rep,name=fills,proto3" json:"fills,omitempty"`
|
||||
EquityCurve []*BacktestEquityPoint `protobuf:"bytes,6,rep,name=equity_curve,json=equityCurve,proto3" json:"equity_curve,omitempty"`
|
||||
// risk_rejections lists orders the paper run's risk gate or portfolio refused.
|
||||
// It is additive: an empty list means every decided order cleared risk, while a
|
||||
// non-empty list lets the operator surface a blocked risk summary without faking
|
||||
// broker behaviour. The fill path stays unchanged; this only exposes rejections
|
||||
// the engine already tracked internally.
|
||||
RiskRejections []*PaperRiskRejection `protobuf:"bytes,7,rep,name=risk_rejections,json=riskRejections,proto3" json:"risk_rejections,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PaperTradingState) Reset() {
|
||||
|
|
@ -313,6 +319,601 @@ func (x *PaperTradingState) GetEquityCurve() []*BacktestEquityPoint {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *PaperTradingState) GetRiskRejections() []*PaperRiskRejection {
|
||||
if x != nil {
|
||||
return x.RiskRejections
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PaperRiskRejection summarises a single order that the paper run did not fill
|
||||
// because a risk decision or portfolio application denied it. It mirrors the
|
||||
// worker-owned RejectedOrder so operator surfaces can distinguish clear vs
|
||||
// blocked risk without inspecting raw engine state.
|
||||
type PaperRiskRejection struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
InstrumentId string `protobuf:"bytes,1,opt,name=instrument_id,json=instrumentId,proto3" json:"instrument_id,omitempty"`
|
||||
Side string `protobuf:"bytes,2,opt,name=side,proto3" json:"side,omitempty"`
|
||||
Quantity *Quantity `protobuf:"bytes,3,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||
Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
TimestampUnixMs int64 `protobuf:"varint,5,opt,name=timestamp_unix_ms,json=timestampUnixMs,proto3" json:"timestamp_unix_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PaperRiskRejection) Reset() {
|
||||
*x = PaperRiskRejection{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PaperRiskRejection) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PaperRiskRejection) ProtoMessage() {}
|
||||
|
||||
func (x *PaperRiskRejection) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[5]
|
||||
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 PaperRiskRejection.ProtoReflect.Descriptor instead.
|
||||
func (*PaperRiskRejection) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *PaperRiskRejection) GetInstrumentId() string {
|
||||
if x != nil {
|
||||
return x.InstrumentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperRiskRejection) GetSide() string {
|
||||
if x != nil {
|
||||
return x.Side
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperRiskRejection) GetQuantity() *Quantity {
|
||||
if x != nil {
|
||||
return x.Quantity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *PaperRiskRejection) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperRiskRejection) GetTimestampUnixMs() int64 {
|
||||
if x != nil {
|
||||
return x.TimestampUnixMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PaperOrder is an operator-submitted virtual order tracked against a started
|
||||
// paper account. Side/type/status stay string-typed to match the existing paper
|
||||
// summary shapes (PaperRiskRejection.side) and to keep the lifecycle additive
|
||||
// without introducing new enums. No real broker order is ever created.
|
||||
type PaperOrder struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
|
||||
AccountId string `protobuf:"bytes,2,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
|
||||
InstrumentId string `protobuf:"bytes,3,opt,name=instrument_id,json=instrumentId,proto3" json:"instrument_id,omitempty"`
|
||||
// side is "buy" or "sell".
|
||||
Side string `protobuf:"bytes,4,opt,name=side,proto3" json:"side,omitempty"`
|
||||
Quantity *Quantity `protobuf:"bytes,5,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||
// type is "market" or "limit".
|
||||
Type string `protobuf:"bytes,6,opt,name=type,proto3" json:"type,omitempty"`
|
||||
// limit_price is only set for a limit order.
|
||||
LimitPrice *Price `protobuf:"bytes,7,opt,name=limit_price,json=limitPrice,proto3" json:"limit_price,omitempty"`
|
||||
// status is "pending", "filled", "canceled", or "rejected".
|
||||
Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"`
|
||||
// reason is populated when status is "rejected" (risk or portfolio denial).
|
||||
Reason string `protobuf:"bytes,9,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
// fill is populated when status is "filled"; it reuses the BacktestTrade shape
|
||||
// so a fill summary carries instrument/side/quantity/price/timestamp.
|
||||
Fill *BacktestTrade `protobuf:"bytes,10,opt,name=fill,proto3" json:"fill,omitempty"`
|
||||
CreatedAtUnixMs int64 `protobuf:"varint,11,opt,name=created_at_unix_ms,json=createdAtUnixMs,proto3" json:"created_at_unix_ms,omitempty"`
|
||||
UpdatedAtUnixMs int64 `protobuf:"varint,12,opt,name=updated_at_unix_ms,json=updatedAtUnixMs,proto3" json:"updated_at_unix_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PaperOrder) Reset() {
|
||||
*x = PaperOrder{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PaperOrder) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PaperOrder) ProtoMessage() {}
|
||||
|
||||
func (x *PaperOrder) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PaperOrder.ProtoReflect.Descriptor instead.
|
||||
func (*PaperOrder) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetOrderId() string {
|
||||
if x != nil {
|
||||
return x.OrderId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetInstrumentId() string {
|
||||
if x != nil {
|
||||
return x.InstrumentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetSide() string {
|
||||
if x != nil {
|
||||
return x.Side
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetQuantity() *Quantity {
|
||||
if x != nil {
|
||||
return x.Quantity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetType() string {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetLimitPrice() *Price {
|
||||
if x != nil {
|
||||
return x.LimitPrice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetFill() *BacktestTrade {
|
||||
if x != nil {
|
||||
return x.Fill
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetCreatedAtUnixMs() int64 {
|
||||
if x != nil {
|
||||
return x.CreatedAtUnixMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *PaperOrder) GetUpdatedAtUnixMs() int64 {
|
||||
if x != nil {
|
||||
return x.UpdatedAtUnixMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type SubmitPaperOrderRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
|
||||
InstrumentId string `protobuf:"bytes,2,opt,name=instrument_id,json=instrumentId,proto3" json:"instrument_id,omitempty"`
|
||||
Side string `protobuf:"bytes,3,opt,name=side,proto3" json:"side,omitempty"`
|
||||
Quantity *Quantity `protobuf:"bytes,4,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||
Type string `protobuf:"bytes,5,opt,name=type,proto3" json:"type,omitempty"`
|
||||
LimitPrice *Price `protobuf:"bytes,6,opt,name=limit_price,json=limitPrice,proto3" json:"limit_price,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) Reset() {
|
||||
*x = SubmitPaperOrderRequest{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SubmitPaperOrderRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SubmitPaperOrderRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SubmitPaperOrderRequest) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) GetInstrumentId() string {
|
||||
if x != nil {
|
||||
return x.InstrumentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) GetSide() string {
|
||||
if x != nil {
|
||||
return x.Side
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) GetQuantity() *Quantity {
|
||||
if x != nil {
|
||||
return x.Quantity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) GetType() string {
|
||||
if x != nil {
|
||||
return x.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderRequest) GetLimitPrice() *Price {
|
||||
if x != nil {
|
||||
return x.LimitPrice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SubmitPaperOrderResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Order *PaperOrder `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
|
||||
Error *ErrorInfo `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderResponse) Reset() {
|
||||
*x = SubmitPaperOrderResponse{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SubmitPaperOrderResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SubmitPaperOrderResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_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 SubmitPaperOrderResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SubmitPaperOrderResponse) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderResponse) GetOrder() *PaperOrder {
|
||||
if x != nil {
|
||||
return x.Order
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SubmitPaperOrderResponse) GetError() *ErrorInfo {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CancelPaperOrderRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
|
||||
OrderId string `protobuf:"bytes,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderRequest) Reset() {
|
||||
*x = CancelPaperOrderRequest{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CancelPaperOrderRequest) ProtoMessage() {}
|
||||
|
||||
func (x *CancelPaperOrderRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_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 CancelPaperOrderRequest.ProtoReflect.Descriptor instead.
|
||||
func (*CancelPaperOrderRequest) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderRequest) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderRequest) GetOrderId() string {
|
||||
if x != nil {
|
||||
return x.OrderId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelPaperOrderResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Order *PaperOrder `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
|
||||
Error *ErrorInfo `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderResponse) Reset() {
|
||||
*x = CancelPaperOrderResponse{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CancelPaperOrderResponse) ProtoMessage() {}
|
||||
|
||||
func (x *CancelPaperOrderResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[10]
|
||||
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 CancelPaperOrderResponse.ProtoReflect.Descriptor instead.
|
||||
func (*CancelPaperOrderResponse) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderResponse) GetOrder() *PaperOrder {
|
||||
if x != nil {
|
||||
return x.Order
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *CancelPaperOrderResponse) GetError() *ErrorInfo {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FillPaperOrderRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
|
||||
OrderId string `protobuf:"bytes,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
|
||||
// fill_price is the simulated execution price for a market order. A limit order
|
||||
// executes at its own limit price, so fill_price may be omitted for it.
|
||||
FillPrice *Price `protobuf:"bytes,3,opt,name=fill_price,json=fillPrice,proto3" json:"fill_price,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderRequest) Reset() {
|
||||
*x = FillPaperOrderRequest{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*FillPaperOrderRequest) ProtoMessage() {}
|
||||
|
||||
func (x *FillPaperOrderRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[11]
|
||||
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 FillPaperOrderRequest.ProtoReflect.Descriptor instead.
|
||||
func (*FillPaperOrderRequest) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderRequest) GetAccountId() string {
|
||||
if x != nil {
|
||||
return x.AccountId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderRequest) GetOrderId() string {
|
||||
if x != nil {
|
||||
return x.OrderId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderRequest) GetFillPrice() *Price {
|
||||
if x != nil {
|
||||
return x.FillPrice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FillPaperOrderResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Order *PaperOrder `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
|
||||
// state is the account snapshot after the fill so the operator sees the cash
|
||||
// and position effect without a second query. For a rejected order it carries
|
||||
// the unchanged account state.
|
||||
State *PaperTradingState `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"`
|
||||
Error *ErrorInfo `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderResponse) Reset() {
|
||||
*x = FillPaperOrderResponse{}
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*FillPaperOrderResponse) ProtoMessage() {}
|
||||
|
||||
func (x *FillPaperOrderResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_alt_v1_paper_trading_proto_msgTypes[12]
|
||||
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 FillPaperOrderResponse.ProtoReflect.Descriptor instead.
|
||||
func (*FillPaperOrderResponse) Descriptor() ([]byte, []int) {
|
||||
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderResponse) GetOrder() *PaperOrder {
|
||||
if x != nil {
|
||||
return x.Order
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderResponse) GetState() *PaperTradingState {
|
||||
if x != nil {
|
||||
return x.State
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *FillPaperOrderResponse) GetError() *ErrorInfo {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_alt_v1_paper_trading_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_alt_v1_paper_trading_proto_rawDesc = "" +
|
||||
|
|
@ -331,7 +932,7 @@ const file_alt_v1_paper_trading_proto_rawDesc = "" +
|
|||
"account_id\x18\x01 \x01(\tR\taccountId\"x\n" +
|
||||
"\x1cGetPaperTradingStateResponse\x12/\n" +
|
||||
"\x05state\x18\x01 \x01(\v2\x19.alt.v1.PaperTradingStateR\x05state\x12'\n" +
|
||||
"\x05error\x18\x02 \x01(\v2\x11.alt.v1.ErrorInfoR\x05error\"\xa1\x02\n" +
|
||||
"\x05error\x18\x02 \x01(\v2\x11.alt.v1.ErrorInfoR\x05error\"\xe6\x02\n" +
|
||||
"\x11PaperTradingState\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\x12%\n" +
|
||||
|
|
@ -339,7 +940,60 @@ const file_alt_v1_paper_trading_proto_rawDesc = "" +
|
|||
"\x04cash\x18\x03 \x01(\v2\r.alt.v1.PriceR\x04cash\x126\n" +
|
||||
"\tpositions\x18\x04 \x03(\v2\x18.alt.v1.BacktestPositionR\tpositions\x12+\n" +
|
||||
"\x05fills\x18\x05 \x03(\v2\x15.alt.v1.BacktestTradeR\x05fills\x12>\n" +
|
||||
"\fequity_curve\x18\x06 \x03(\v2\x1b.alt.v1.BacktestEquityPointR\vequityCurveBCZAgit.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1;altv1b\x06proto3"
|
||||
"\fequity_curve\x18\x06 \x03(\v2\x1b.alt.v1.BacktestEquityPointR\vequityCurve\x12C\n" +
|
||||
"\x0frisk_rejections\x18\a \x03(\v2\x1a.alt.v1.PaperRiskRejectionR\x0eriskRejections\"\xbf\x01\n" +
|
||||
"\x12PaperRiskRejection\x12#\n" +
|
||||
"\rinstrument_id\x18\x01 \x01(\tR\finstrumentId\x12\x12\n" +
|
||||
"\x04side\x18\x02 \x01(\tR\x04side\x12,\n" +
|
||||
"\bquantity\x18\x03 \x01(\v2\x10.alt.v1.QuantityR\bquantity\x12\x16\n" +
|
||||
"\x06reason\x18\x04 \x01(\tR\x06reason\x12*\n" +
|
||||
"\x11timestamp_unix_ms\x18\x05 \x01(\x03R\x0ftimestampUnixMs\"\xa6\x03\n" +
|
||||
"\n" +
|
||||
"PaperOrder\x12\x19\n" +
|
||||
"\border_id\x18\x01 \x01(\tR\aorderId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x02 \x01(\tR\taccountId\x12#\n" +
|
||||
"\rinstrument_id\x18\x03 \x01(\tR\finstrumentId\x12\x12\n" +
|
||||
"\x04side\x18\x04 \x01(\tR\x04side\x12,\n" +
|
||||
"\bquantity\x18\x05 \x01(\v2\x10.alt.v1.QuantityR\bquantity\x12\x12\n" +
|
||||
"\x04type\x18\x06 \x01(\tR\x04type\x12.\n" +
|
||||
"\vlimit_price\x18\a \x01(\v2\r.alt.v1.PriceR\n" +
|
||||
"limitPrice\x12\x16\n" +
|
||||
"\x06status\x18\b \x01(\tR\x06status\x12\x16\n" +
|
||||
"\x06reason\x18\t \x01(\tR\x06reason\x12)\n" +
|
||||
"\x04fill\x18\n" +
|
||||
" \x01(\v2\x15.alt.v1.BacktestTradeR\x04fill\x12+\n" +
|
||||
"\x12created_at_unix_ms\x18\v \x01(\x03R\x0fcreatedAtUnixMs\x12+\n" +
|
||||
"\x12updated_at_unix_ms\x18\f \x01(\x03R\x0fupdatedAtUnixMs\"\xe3\x01\n" +
|
||||
"\x17SubmitPaperOrderRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\x12#\n" +
|
||||
"\rinstrument_id\x18\x02 \x01(\tR\finstrumentId\x12\x12\n" +
|
||||
"\x04side\x18\x03 \x01(\tR\x04side\x12,\n" +
|
||||
"\bquantity\x18\x04 \x01(\v2\x10.alt.v1.QuantityR\bquantity\x12\x12\n" +
|
||||
"\x04type\x18\x05 \x01(\tR\x04type\x12.\n" +
|
||||
"\vlimit_price\x18\x06 \x01(\v2\r.alt.v1.PriceR\n" +
|
||||
"limitPrice\"m\n" +
|
||||
"\x18SubmitPaperOrderResponse\x12(\n" +
|
||||
"\x05order\x18\x01 \x01(\v2\x12.alt.v1.PaperOrderR\x05order\x12'\n" +
|
||||
"\x05error\x18\x02 \x01(\v2\x11.alt.v1.ErrorInfoR\x05error\"S\n" +
|
||||
"\x17CancelPaperOrderRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\x12\x19\n" +
|
||||
"\border_id\x18\x02 \x01(\tR\aorderId\"m\n" +
|
||||
"\x18CancelPaperOrderResponse\x12(\n" +
|
||||
"\x05order\x18\x01 \x01(\v2\x12.alt.v1.PaperOrderR\x05order\x12'\n" +
|
||||
"\x05error\x18\x02 \x01(\v2\x11.alt.v1.ErrorInfoR\x05error\"\x7f\n" +
|
||||
"\x15FillPaperOrderRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"account_id\x18\x01 \x01(\tR\taccountId\x12\x19\n" +
|
||||
"\border_id\x18\x02 \x01(\tR\aorderId\x12,\n" +
|
||||
"\n" +
|
||||
"fill_price\x18\x03 \x01(\v2\r.alt.v1.PriceR\tfillPrice\"\x9c\x01\n" +
|
||||
"\x16FillPaperOrderResponse\x12(\n" +
|
||||
"\x05order\x18\x01 \x01(\v2\x12.alt.v1.PaperOrderR\x05order\x12/\n" +
|
||||
"\x05state\x18\x02 \x01(\v2\x19.alt.v1.PaperTradingStateR\x05state\x12'\n" +
|
||||
"\x05error\x18\x03 \x01(\v2\x11.alt.v1.ErrorInfoR\x05errorBCZAgit.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1;altv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_alt_v1_paper_trading_proto_rawDescOnce sync.Once
|
||||
|
|
@ -353,38 +1007,62 @@ func file_alt_v1_paper_trading_proto_rawDescGZIP() []byte {
|
|||
return file_alt_v1_paper_trading_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_alt_v1_paper_trading_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
|
||||
var file_alt_v1_paper_trading_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_alt_v1_paper_trading_proto_goTypes = []any{
|
||||
(*StartPaperTradingRequest)(nil), // 0: alt.v1.StartPaperTradingRequest
|
||||
(*StartPaperTradingResponse)(nil), // 1: alt.v1.StartPaperTradingResponse
|
||||
(*GetPaperTradingStateRequest)(nil), // 2: alt.v1.GetPaperTradingStateRequest
|
||||
(*GetPaperTradingStateResponse)(nil), // 3: alt.v1.GetPaperTradingStateResponse
|
||||
(*PaperTradingState)(nil), // 4: alt.v1.PaperTradingState
|
||||
(*BacktestRunSpec)(nil), // 5: alt.v1.BacktestRunSpec
|
||||
(*Price)(nil), // 6: alt.v1.Price
|
||||
(*ErrorInfo)(nil), // 7: alt.v1.ErrorInfo
|
||||
(*BacktestRun)(nil), // 8: alt.v1.BacktestRun
|
||||
(*BacktestPosition)(nil), // 9: alt.v1.BacktestPosition
|
||||
(*BacktestTrade)(nil), // 10: alt.v1.BacktestTrade
|
||||
(*BacktestEquityPoint)(nil), // 11: alt.v1.BacktestEquityPoint
|
||||
(*PaperRiskRejection)(nil), // 5: alt.v1.PaperRiskRejection
|
||||
(*PaperOrder)(nil), // 6: alt.v1.PaperOrder
|
||||
(*SubmitPaperOrderRequest)(nil), // 7: alt.v1.SubmitPaperOrderRequest
|
||||
(*SubmitPaperOrderResponse)(nil), // 8: alt.v1.SubmitPaperOrderResponse
|
||||
(*CancelPaperOrderRequest)(nil), // 9: alt.v1.CancelPaperOrderRequest
|
||||
(*CancelPaperOrderResponse)(nil), // 10: alt.v1.CancelPaperOrderResponse
|
||||
(*FillPaperOrderRequest)(nil), // 11: alt.v1.FillPaperOrderRequest
|
||||
(*FillPaperOrderResponse)(nil), // 12: alt.v1.FillPaperOrderResponse
|
||||
(*BacktestRunSpec)(nil), // 13: alt.v1.BacktestRunSpec
|
||||
(*Price)(nil), // 14: alt.v1.Price
|
||||
(*ErrorInfo)(nil), // 15: alt.v1.ErrorInfo
|
||||
(*BacktestRun)(nil), // 16: alt.v1.BacktestRun
|
||||
(*BacktestPosition)(nil), // 17: alt.v1.BacktestPosition
|
||||
(*BacktestTrade)(nil), // 18: alt.v1.BacktestTrade
|
||||
(*BacktestEquityPoint)(nil), // 19: alt.v1.BacktestEquityPoint
|
||||
(*Quantity)(nil), // 20: alt.v1.Quantity
|
||||
}
|
||||
var file_alt_v1_paper_trading_proto_depIdxs = []int32{
|
||||
5, // 0: alt.v1.StartPaperTradingRequest.spec:type_name -> alt.v1.BacktestRunSpec
|
||||
6, // 1: alt.v1.StartPaperTradingRequest.starting_cash:type_name -> alt.v1.Price
|
||||
13, // 0: alt.v1.StartPaperTradingRequest.spec:type_name -> alt.v1.BacktestRunSpec
|
||||
14, // 1: alt.v1.StartPaperTradingRequest.starting_cash:type_name -> alt.v1.Price
|
||||
4, // 2: alt.v1.StartPaperTradingResponse.state:type_name -> alt.v1.PaperTradingState
|
||||
7, // 3: alt.v1.StartPaperTradingResponse.error:type_name -> alt.v1.ErrorInfo
|
||||
15, // 3: alt.v1.StartPaperTradingResponse.error:type_name -> alt.v1.ErrorInfo
|
||||
4, // 4: alt.v1.GetPaperTradingStateResponse.state:type_name -> alt.v1.PaperTradingState
|
||||
7, // 5: alt.v1.GetPaperTradingStateResponse.error:type_name -> alt.v1.ErrorInfo
|
||||
8, // 6: alt.v1.PaperTradingState.run:type_name -> alt.v1.BacktestRun
|
||||
6, // 7: alt.v1.PaperTradingState.cash:type_name -> alt.v1.Price
|
||||
9, // 8: alt.v1.PaperTradingState.positions:type_name -> alt.v1.BacktestPosition
|
||||
10, // 9: alt.v1.PaperTradingState.fills:type_name -> alt.v1.BacktestTrade
|
||||
11, // 10: alt.v1.PaperTradingState.equity_curve:type_name -> alt.v1.BacktestEquityPoint
|
||||
11, // [11:11] is the sub-list for method output_type
|
||||
11, // [11:11] is the sub-list for method input_type
|
||||
11, // [11:11] is the sub-list for extension type_name
|
||||
11, // [11:11] is the sub-list for extension extendee
|
||||
0, // [0:11] is the sub-list for field type_name
|
||||
15, // 5: alt.v1.GetPaperTradingStateResponse.error:type_name -> alt.v1.ErrorInfo
|
||||
16, // 6: alt.v1.PaperTradingState.run:type_name -> alt.v1.BacktestRun
|
||||
14, // 7: alt.v1.PaperTradingState.cash:type_name -> alt.v1.Price
|
||||
17, // 8: alt.v1.PaperTradingState.positions:type_name -> alt.v1.BacktestPosition
|
||||
18, // 9: alt.v1.PaperTradingState.fills:type_name -> alt.v1.BacktestTrade
|
||||
19, // 10: alt.v1.PaperTradingState.equity_curve:type_name -> alt.v1.BacktestEquityPoint
|
||||
5, // 11: alt.v1.PaperTradingState.risk_rejections:type_name -> alt.v1.PaperRiskRejection
|
||||
20, // 12: alt.v1.PaperRiskRejection.quantity:type_name -> alt.v1.Quantity
|
||||
20, // 13: alt.v1.PaperOrder.quantity:type_name -> alt.v1.Quantity
|
||||
14, // 14: alt.v1.PaperOrder.limit_price:type_name -> alt.v1.Price
|
||||
18, // 15: alt.v1.PaperOrder.fill:type_name -> alt.v1.BacktestTrade
|
||||
20, // 16: alt.v1.SubmitPaperOrderRequest.quantity:type_name -> alt.v1.Quantity
|
||||
14, // 17: alt.v1.SubmitPaperOrderRequest.limit_price:type_name -> alt.v1.Price
|
||||
6, // 18: alt.v1.SubmitPaperOrderResponse.order:type_name -> alt.v1.PaperOrder
|
||||
15, // 19: alt.v1.SubmitPaperOrderResponse.error:type_name -> alt.v1.ErrorInfo
|
||||
6, // 20: alt.v1.CancelPaperOrderResponse.order:type_name -> alt.v1.PaperOrder
|
||||
15, // 21: alt.v1.CancelPaperOrderResponse.error:type_name -> alt.v1.ErrorInfo
|
||||
14, // 22: alt.v1.FillPaperOrderRequest.fill_price:type_name -> alt.v1.Price
|
||||
6, // 23: alt.v1.FillPaperOrderResponse.order:type_name -> alt.v1.PaperOrder
|
||||
4, // 24: alt.v1.FillPaperOrderResponse.state:type_name -> alt.v1.PaperTradingState
|
||||
15, // 25: alt.v1.FillPaperOrderResponse.error:type_name -> alt.v1.ErrorInfo
|
||||
26, // [26:26] is the sub-list for method output_type
|
||||
26, // [26:26] is the sub-list for method input_type
|
||||
26, // [26:26] is the sub-list for extension type_name
|
||||
26, // [26:26] is the sub-list for extension extendee
|
||||
0, // [0:26] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_alt_v1_paper_trading_proto_init() }
|
||||
|
|
@ -401,7 +1079,7 @@ func file_alt_v1_paper_trading_proto_init() {
|
|||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_alt_v1_paper_trading_proto_rawDesc), len(file_alt_v1_paper_trading_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 5,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -40,4 +40,89 @@ message PaperTradingState {
|
|||
repeated BacktestPosition positions = 4;
|
||||
repeated BacktestTrade fills = 5;
|
||||
repeated BacktestEquityPoint equity_curve = 6;
|
||||
// risk_rejections lists orders the paper run's risk gate or portfolio refused.
|
||||
// It is additive: an empty list means every decided order cleared risk, while a
|
||||
// non-empty list lets the operator surface a blocked risk summary without faking
|
||||
// broker behaviour. The fill path stays unchanged; this only exposes rejections
|
||||
// the engine already tracked internally.
|
||||
repeated PaperRiskRejection risk_rejections = 7;
|
||||
}
|
||||
|
||||
// PaperRiskRejection summarises a single order that the paper run did not fill
|
||||
// because a risk decision or portfolio application denied it. It mirrors the
|
||||
// worker-owned RejectedOrder so operator surfaces can distinguish clear vs
|
||||
// blocked risk without inspecting raw engine state.
|
||||
message PaperRiskRejection {
|
||||
string instrument_id = 1;
|
||||
string side = 2;
|
||||
Quantity quantity = 3;
|
||||
string reason = 4;
|
||||
int64 timestamp_unix_ms = 5;
|
||||
}
|
||||
|
||||
// PaperOrder is an operator-submitted virtual order tracked against a started
|
||||
// paper account. Side/type/status stay string-typed to match the existing paper
|
||||
// summary shapes (PaperRiskRejection.side) and to keep the lifecycle additive
|
||||
// without introducing new enums. No real broker order is ever created.
|
||||
message PaperOrder {
|
||||
string order_id = 1;
|
||||
string account_id = 2;
|
||||
string instrument_id = 3;
|
||||
// side is "buy" or "sell".
|
||||
string side = 4;
|
||||
Quantity quantity = 5;
|
||||
// type is "market" or "limit".
|
||||
string type = 6;
|
||||
// limit_price is only set for a limit order.
|
||||
Price limit_price = 7;
|
||||
// status is "pending", "filled", "canceled", or "rejected".
|
||||
string status = 8;
|
||||
// reason is populated when status is "rejected" (risk or portfolio denial).
|
||||
string reason = 9;
|
||||
// fill is populated when status is "filled"; it reuses the BacktestTrade shape
|
||||
// so a fill summary carries instrument/side/quantity/price/timestamp.
|
||||
BacktestTrade fill = 10;
|
||||
int64 created_at_unix_ms = 11;
|
||||
int64 updated_at_unix_ms = 12;
|
||||
}
|
||||
|
||||
message SubmitPaperOrderRequest {
|
||||
string account_id = 1;
|
||||
string instrument_id = 2;
|
||||
string side = 3;
|
||||
Quantity quantity = 4;
|
||||
string type = 5;
|
||||
Price limit_price = 6;
|
||||
}
|
||||
|
||||
message SubmitPaperOrderResponse {
|
||||
PaperOrder order = 1;
|
||||
ErrorInfo error = 2;
|
||||
}
|
||||
|
||||
message CancelPaperOrderRequest {
|
||||
string account_id = 1;
|
||||
string order_id = 2;
|
||||
}
|
||||
|
||||
message CancelPaperOrderResponse {
|
||||
PaperOrder order = 1;
|
||||
ErrorInfo error = 2;
|
||||
}
|
||||
|
||||
message FillPaperOrderRequest {
|
||||
string account_id = 1;
|
||||
string order_id = 2;
|
||||
// fill_price is the simulated execution price for a market order. A limit order
|
||||
// executes at its own limit price, so fill_price may be omitted for it.
|
||||
Price fill_price = 3;
|
||||
}
|
||||
|
||||
message FillPaperOrderResponse {
|
||||
PaperOrder order = 1;
|
||||
// state is the account snapshot after the fill so the operator sees the cash
|
||||
// and position effect without a second query. For a rejected order it carries
|
||||
// the unchanged account state.
|
||||
PaperTradingState state = 2;
|
||||
ErrorInfo error = 3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ func messageFactories() []func() proto.Message {
|
|||
func() proto.Message { return &altv1.GetPaperTradingStateRequest{} },
|
||||
func() proto.Message { return &altv1.GetPaperTradingStateResponse{} },
|
||||
func() proto.Message { return &altv1.PaperTradingState{} },
|
||||
// paper order lifecycle surface: submit / cancel / fill
|
||||
func() proto.Message { return &altv1.SubmitPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.SubmitPaperOrderResponse{} },
|
||||
func() proto.Message { return &altv1.CancelPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.CancelPaperOrderResponse{} },
|
||||
func() proto.Message { return &altv1.FillPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.FillPaperOrderResponse{} },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,11 @@ type fakeWorkerClient struct {
|
|||
instReq *altv1.ListInstrumentsRequest
|
||||
barsReq *altv1.ListBarsRequest
|
||||
importReq *altv1.ImportDailyBarsRequest
|
||||
paperStartReq *altv1.StartPaperTradingRequest
|
||||
paperStateReq *altv1.GetPaperTradingStateRequest
|
||||
paperStartReq *altv1.StartPaperTradingRequest
|
||||
paperStateReq *altv1.GetPaperTradingStateRequest
|
||||
paperSubmitReq *altv1.SubmitPaperOrderRequest
|
||||
paperCancelReq *altv1.CancelPaperOrderRequest
|
||||
paperFillReq *altv1.FillPaperOrderRequest
|
||||
|
||||
startRes *altv1.StartBacktestResponse
|
||||
getRunRes *altv1.GetBacktestRunResponse
|
||||
|
|
@ -36,8 +39,11 @@ type fakeWorkerClient struct {
|
|||
instRes *altv1.ListInstrumentsResponse
|
||||
barsRes *altv1.ListBarsResponse
|
||||
importRes *altv1.ImportDailyBarsResponse
|
||||
paperStartRes *altv1.StartPaperTradingResponse
|
||||
paperStateRes *altv1.GetPaperTradingStateResponse
|
||||
paperStartRes *altv1.StartPaperTradingResponse
|
||||
paperStateRes *altv1.GetPaperTradingStateResponse
|
||||
paperSubmitRes *altv1.SubmitPaperOrderResponse
|
||||
paperCancelRes *altv1.CancelPaperOrderResponse
|
||||
paperFillRes *altv1.FillPaperOrderResponse
|
||||
|
||||
err error
|
||||
connectErr error
|
||||
|
|
@ -93,6 +99,18 @@ func (f *fakeWorkerClient) GetPaperTradingState(ctx context.Context, req *altv1.
|
|||
f.paperStateReq = req
|
||||
return f.paperStateRes, f.err
|
||||
}
|
||||
func (f *fakeWorkerClient) SubmitPaperOrder(ctx context.Context, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
f.paperSubmitReq = req
|
||||
return f.paperSubmitRes, f.err
|
||||
}
|
||||
func (f *fakeWorkerClient) CancelPaperOrder(ctx context.Context, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
f.paperCancelReq = req
|
||||
return f.paperCancelRes, f.err
|
||||
}
|
||||
func (f *fakeWorkerClient) FillPaperOrder(ctx context.Context, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
f.paperFillReq = req
|
||||
return f.paperFillRes, f.err
|
||||
}
|
||||
func (f *fakeWorkerClient) ListInstruments(ctx context.Context, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) {
|
||||
f.instReq = req
|
||||
return f.instRes, f.err
|
||||
|
|
|
|||
|
|
@ -30,6 +30,30 @@ func paperHandlers(worker workerclient.WorkerClient) []sessionHandler {
|
|||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
requestType: protoSocket.TypeNameOf(&altv1.SubmitPaperOrderRequest{}),
|
||||
register: func(client *protoSocket.WsClient) {
|
||||
protoSocket.AddRequestListenerTyped[*altv1.SubmitPaperOrderRequest, *altv1.SubmitPaperOrderResponse](&client.Communicator, func(req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
return handleSubmitPaperOrder(worker, req)
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
requestType: protoSocket.TypeNameOf(&altv1.CancelPaperOrderRequest{}),
|
||||
register: func(client *protoSocket.WsClient) {
|
||||
protoSocket.AddRequestListenerTyped[*altv1.CancelPaperOrderRequest, *altv1.CancelPaperOrderResponse](&client.Communicator, func(req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
return handleCancelPaperOrder(worker, req)
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
requestType: protoSocket.TypeNameOf(&altv1.FillPaperOrderRequest{}),
|
||||
register: func(client *protoSocket.WsClient) {
|
||||
protoSocket.AddRequestListenerTyped[*altv1.FillPaperOrderRequest, *altv1.FillPaperOrderResponse](&client.Communicator, func(req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
return handleFillPaperOrder(worker, req)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,3 +106,78 @@ func handleGetPaperTradingState(worker workerclient.WorkerClient, req *altv1.Get
|
|||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func handleSubmitPaperOrder(worker workerclient.WorkerClient, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
if req.GetAccountId() == "" {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: invalidRequest("account_id is required")}, nil
|
||||
}
|
||||
if req.GetInstrumentId() == "" {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: invalidRequest("instrument_id is required")}, nil
|
||||
}
|
||||
if worker == nil {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: workerUnavailable()}, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
|
||||
defer cancel()
|
||||
if err := worker.Connect(ctx); err != nil {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: workerErrorInfo(err)}, nil
|
||||
}
|
||||
res, err := worker.SubmitPaperOrder(ctx, req)
|
||||
if err != nil {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: workerErrorInfo(err)}, nil
|
||||
}
|
||||
if res == nil {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: internalError("worker returned no submit order response")}, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func handleCancelPaperOrder(worker workerclient.WorkerClient, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
if req.GetAccountId() == "" {
|
||||
return &altv1.CancelPaperOrderResponse{Error: invalidRequest("account_id is required")}, nil
|
||||
}
|
||||
if req.GetOrderId() == "" {
|
||||
return &altv1.CancelPaperOrderResponse{Error: invalidRequest("order_id is required")}, nil
|
||||
}
|
||||
if worker == nil {
|
||||
return &altv1.CancelPaperOrderResponse{Error: workerUnavailable()}, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
|
||||
defer cancel()
|
||||
if err := worker.Connect(ctx); err != nil {
|
||||
return &altv1.CancelPaperOrderResponse{Error: workerErrorInfo(err)}, nil
|
||||
}
|
||||
res, err := worker.CancelPaperOrder(ctx, req)
|
||||
if err != nil {
|
||||
return &altv1.CancelPaperOrderResponse{Error: workerErrorInfo(err)}, nil
|
||||
}
|
||||
if res == nil {
|
||||
return &altv1.CancelPaperOrderResponse{Error: internalError("worker returned no cancel order response")}, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func handleFillPaperOrder(worker workerclient.WorkerClient, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
if req.GetAccountId() == "" {
|
||||
return &altv1.FillPaperOrderResponse{Error: invalidRequest("account_id is required")}, nil
|
||||
}
|
||||
if req.GetOrderId() == "" {
|
||||
return &altv1.FillPaperOrderResponse{Error: invalidRequest("order_id is required")}, nil
|
||||
}
|
||||
if worker == nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: workerUnavailable()}, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
|
||||
defer cancel()
|
||||
if err := worker.Connect(ctx); err != nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: workerErrorInfo(err)}, nil
|
||||
}
|
||||
res, err := worker.FillPaperOrder(ctx, req)
|
||||
if err != nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: workerErrorInfo(err)}, nil
|
||||
}
|
||||
if res == nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: internalError("worker returned no fill order response")}, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,134 @@ func TestHandleGetPaperTradingStateNilWorkerResponse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderForwards(t *testing.T) {
|
||||
fake := &fakeWorkerClient{paperSubmitRes: &altv1.SubmitPaperOrderResponse{
|
||||
Order: &altv1.PaperOrder{OrderId: "paper-order-paper-1-1", Status: "pending"},
|
||||
}}
|
||||
|
||||
resp, err := handleSubmitPaperOrder(fake, &altv1.SubmitPaperOrderRequest{
|
||||
AccountId: "paper-1",
|
||||
InstrumentId: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fake.paperSubmitReq == nil {
|
||||
t.Fatal("expected request to be forwarded to worker")
|
||||
}
|
||||
if resp.GetOrder().GetOrderId() != "paper-order-paper-1-1" {
|
||||
t.Errorf("unexpected order id: %q", resp.GetOrder().GetOrderId())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderValidation(t *testing.T) {
|
||||
fake := &fakeWorkerClient{}
|
||||
for i, req := range []*altv1.SubmitPaperOrderRequest{
|
||||
{InstrumentId: "KRX:005930"}, // missing account_id
|
||||
{AccountId: "paper-1"}, // missing instrument_id
|
||||
} {
|
||||
resp, err := handleSubmitPaperOrder(fake, req)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d: expected typed validation response, got error: %v", i, err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
if fake.paperSubmitReq != nil {
|
||||
t.Error("worker must not be called when validation fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderNilWorker(t *testing.T) {
|
||||
resp, err := handleSubmitPaperOrder(nil, &altv1.SubmitPaperOrderRequest{AccountId: "paper-1", InstrumentId: "KRX:005930"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed unavailable response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
|
||||
}
|
||||
|
||||
func TestHandleCancelPaperOrderForwards(t *testing.T) {
|
||||
fake := &fakeWorkerClient{paperCancelRes: &altv1.CancelPaperOrderResponse{
|
||||
Order: &altv1.PaperOrder{OrderId: "paper-order-paper-1-1", Status: "canceled"},
|
||||
}}
|
||||
|
||||
resp, err := handleCancelPaperOrder(fake, &altv1.CancelPaperOrderRequest{AccountId: "paper-1", OrderId: "paper-order-paper-1-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fake.paperCancelReq.GetOrderId() != "paper-order-paper-1-1" {
|
||||
t.Errorf("order id not forwarded unchanged, got %q", fake.paperCancelReq.GetOrderId())
|
||||
}
|
||||
if resp.GetOrder().GetStatus() != "canceled" {
|
||||
t.Errorf("unexpected status: %q", resp.GetOrder().GetStatus())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCancelPaperOrderValidation(t *testing.T) {
|
||||
fake := &fakeWorkerClient{}
|
||||
for i, req := range []*altv1.CancelPaperOrderRequest{
|
||||
{OrderId: "o-1"}, // missing account_id
|
||||
{AccountId: "paper-1"}, // missing order_id
|
||||
} {
|
||||
resp, err := handleCancelPaperOrder(fake, req)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d: expected typed validation response, got error: %v", i, err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
if fake.paperCancelReq != nil {
|
||||
t.Error("worker must not be called when validation fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderForwards(t *testing.T) {
|
||||
fake := &fakeWorkerClient{paperFillRes: &altv1.FillPaperOrderResponse{
|
||||
Order: &altv1.PaperOrder{OrderId: "paper-order-paper-1-1", Status: "filled"},
|
||||
State: &altv1.PaperTradingState{AccountId: "paper-1"},
|
||||
}}
|
||||
|
||||
resp, err := handleFillPaperOrder(fake, &altv1.FillPaperOrderRequest{AccountId: "paper-1", OrderId: "paper-order-paper-1-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if fake.paperFillReq == nil {
|
||||
t.Fatal("expected request to be forwarded to worker")
|
||||
}
|
||||
if resp.GetOrder().GetStatus() != "filled" {
|
||||
t.Errorf("unexpected status: %q", resp.GetOrder().GetStatus())
|
||||
}
|
||||
if resp.GetState().GetAccountId() != "paper-1" {
|
||||
t.Errorf("unexpected state account id: %q", resp.GetState().GetAccountId())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderValidation(t *testing.T) {
|
||||
fake := &fakeWorkerClient{}
|
||||
for i, req := range []*altv1.FillPaperOrderRequest{
|
||||
{OrderId: "o-1"}, // missing account_id
|
||||
{AccountId: "paper-1"}, // missing order_id
|
||||
} {
|
||||
resp, err := handleFillPaperOrder(fake, req)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d: expected typed validation response, got error: %v", i, err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
if fake.paperFillReq != nil {
|
||||
t.Error("worker must not be called when validation fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderMapsTimeout(t *testing.T) {
|
||||
fake := &fakeWorkerClient{err: workerclient.ErrTimeout}
|
||||
resp, err := handleFillPaperOrder(fake, &altv1.FillPaperOrderRequest{AccountId: "paper-1", OrderId: "o-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed timeout response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorTimeout)
|
||||
}
|
||||
|
||||
func TestPaperHandlersRegisteredInSession(t *testing.T) {
|
||||
registered := make(map[string]bool)
|
||||
for _, h := range sessionHandlers(nil) {
|
||||
|
|
@ -149,6 +277,9 @@ func TestPaperHandlersRegisteredInSession(t *testing.T) {
|
|||
for _, req := range []string{
|
||||
protoSocket.TypeNameOf(&altv1.StartPaperTradingRequest{}),
|
||||
protoSocket.TypeNameOf(&altv1.GetPaperTradingStateRequest{}),
|
||||
protoSocket.TypeNameOf(&altv1.SubmitPaperOrderRequest{}),
|
||||
protoSocket.TypeNameOf(&altv1.CancelPaperOrderRequest{}),
|
||||
protoSocket.TypeNameOf(&altv1.FillPaperOrderRequest{}),
|
||||
} {
|
||||
if !registered[req] {
|
||||
t.Errorf("missing API handler registration for %q", req)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ type WorkerClient interface {
|
|||
StartPaperTrading(ctx context.Context, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error)
|
||||
GetPaperTradingState(ctx context.Context, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error)
|
||||
|
||||
// Paper order lifecycle surface. Submit/cancel/fill stay worker-owned; the API
|
||||
// only validates request shape and forwards.
|
||||
SubmitPaperOrder(ctx context.Context, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error)
|
||||
CancelPaperOrder(ctx context.Context, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error)
|
||||
FillPaperOrder(ctx context.Context, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error)
|
||||
|
||||
// Market query surface. The API validates request shape and forwards market
|
||||
// reads to the worker-owned storage boundary.
|
||||
ListInstruments(ctx context.Context, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error)
|
||||
|
|
@ -166,6 +172,18 @@ func (c *socketClient) GetPaperTradingState(ctx context.Context, req *altv1.GetP
|
|||
return sendTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
func (c *socketClient) SubmitPaperOrder(ctx context.Context, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
return sendTyped[*altv1.SubmitPaperOrderRequest, *altv1.SubmitPaperOrderResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
func (c *socketClient) CancelPaperOrder(ctx context.Context, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
return sendTyped[*altv1.CancelPaperOrderRequest, *altv1.CancelPaperOrderResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
func (c *socketClient) FillPaperOrder(ctx context.Context, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
return sendTyped[*altv1.FillPaperOrderRequest, *altv1.FillPaperOrderResponse](c, ctx, req)
|
||||
}
|
||||
|
||||
func (c *socketClient) ListInstruments(ctx context.Context, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) {
|
||||
return sendTyped[*altv1.ListInstrumentsRequest, *altv1.ListInstrumentsResponse](c, ctx, req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@ func messageFactories() []func() proto.Message {
|
|||
func() proto.Message { return &altv1.GetPaperTradingStateRequest{} },
|
||||
func() proto.Message { return &altv1.GetPaperTradingStateResponse{} },
|
||||
func() proto.Message { return &altv1.PaperTradingState{} },
|
||||
// paper order lifecycle surface: submit / cancel / fill
|
||||
func() proto.Message { return &altv1.SubmitPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.SubmitPaperOrderResponse{} },
|
||||
func() proto.Message { return &altv1.CancelPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.CancelPaperOrderResponse{} },
|
||||
func() proto.Message { return &altv1.FillPaperOrderRequest{} },
|
||||
func() proto.Message { return &altv1.FillPaperOrderResponse{} },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -16,6 +18,26 @@ import (
|
|||
// account that has not been started in this process.
|
||||
var ErrAccountNotFound = errors.New("paper trading account not found")
|
||||
|
||||
// Order lifecycle sentinels. They let the socket layer map a lifecycle failure
|
||||
// onto the shared typed error vocabulary: a missing order is not_found, an
|
||||
// out-of-sequence transition or a missing market fill price is invalid_request.
|
||||
var (
|
||||
// ErrOrderNotFound is returned when a submit/cancel/fill targets an order id
|
||||
// that was never recorded for the account.
|
||||
ErrOrderNotFound = errors.New("paper order not found")
|
||||
// ErrOrderNotPending is returned when cancel/fill targets an order that has
|
||||
// already reached a terminal status (filled, canceled, rejected).
|
||||
ErrOrderNotPending = errors.New("paper order is not pending")
|
||||
// ErrFillPriceRequired is returned when a market order is filled without a
|
||||
// simulated execution price; a market order has no inherent price.
|
||||
ErrFillPriceRequired = errors.New("fill_price is required for a market order")
|
||||
// ErrInvalidOrderInput is returned when an order lifecycle decimal
|
||||
// (quantity, limit price, fill price) is non-numeric or has the wrong sign.
|
||||
// The socket layer maps it to invalid_request so malformed input is rejected
|
||||
// before it can be stored as a pending order or applied as a fill.
|
||||
ErrInvalidOrderInput = errors.New("invalid paper order input")
|
||||
)
|
||||
|
||||
// StartRequest carries the validated inputs needed to start a paper run for an
|
||||
// account. The socket layer maps the proto request onto this shape so the
|
||||
// service stays decoupled from the wire contract.
|
||||
|
|
@ -33,6 +55,10 @@ type State struct {
|
|||
Positions []backtest.Position
|
||||
Fills []backtest.Fill
|
||||
EquityCurve []backtest.EquityPoint
|
||||
// Rejected carries orders the run's risk gate or portfolio refused, preserved
|
||||
// from the engine snapshot so the socket layer can expose a risk summary
|
||||
// without re-running risk evaluation.
|
||||
Rejected []RejectedOrder
|
||||
}
|
||||
|
||||
// Service is the worker-owned, process-local paper trading runtime. It runs the
|
||||
|
|
@ -46,6 +72,11 @@ type Service struct {
|
|||
|
||||
mu sync.Mutex
|
||||
states map[backtest.PaperAccountID]State
|
||||
// books holds operator-submitted order lifecycle state per account. It is
|
||||
// seeded lazily from a started account's terminal run state on first submit,
|
||||
// so the manual order path continues from where the run left off without a
|
||||
// durable store.
|
||||
books map[backtest.PaperAccountID]*orderBook
|
||||
}
|
||||
|
||||
// NewService builds a paper trading service around an Engine. now defaults to
|
||||
|
|
@ -58,6 +89,7 @@ func NewService(engine *Engine, now func() time.Time) *Service {
|
|||
engine: engine,
|
||||
now: now,
|
||||
states: make(map[backtest.PaperAccountID]State),
|
||||
books: make(map[backtest.PaperAccountID]*orderBook),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,19 +133,30 @@ func (s *Service) StartPaperTrading(ctx context.Context, req StartRequest) (Stat
|
|||
|
||||
s.mu.Lock()
|
||||
s.states[req.AccountID] = state
|
||||
// Restarting an account discards any prior order book so the new run state is
|
||||
// not shadowed by a stale manual portfolio/order sequence. Without this,
|
||||
// GetPaperTradingState would keep returning the old order-book snapshot and
|
||||
// subsequent orders would continue the old id sequence.
|
||||
delete(s.books, req.AccountID)
|
||||
s.mu.Unlock()
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// GetPaperTradingState returns the recorded state for an account or
|
||||
// ErrAccountNotFound when the account was never started in this process.
|
||||
// ErrAccountNotFound when the account was never started in this process. Once an
|
||||
// order book exists for the account (the operator submitted virtual orders), the
|
||||
// live order-book snapshot is returned so the account view reflects manual fills
|
||||
// instead of the stale terminal run state.
|
||||
func (s *Service) GetPaperTradingState(ctx context.Context, accountID backtest.PaperAccountID) (State, error) {
|
||||
if accountID == "" {
|
||||
return State{}, fmt.Errorf("account_id is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if book, ok := s.books[accountID]; ok {
|
||||
return book.snapshot(), nil
|
||||
}
|
||||
state, ok := s.states[accountID]
|
||||
if !ok {
|
||||
return State{}, ErrAccountNotFound
|
||||
|
|
@ -137,5 +180,361 @@ func stateFromSnapshot(run backtest.Run, snap *Snapshot) State {
|
|||
Positions: positions,
|
||||
Fills: snap.Fills,
|
||||
EquityCurve: snap.EquityCurve,
|
||||
Rejected: snap.Rejected,
|
||||
}
|
||||
}
|
||||
|
||||
// PaperOrderStatus is the lifecycle status of an operator-submitted virtual
|
||||
// order. The set is closed: an order starts pending and moves to exactly one of
|
||||
// the terminal statuses.
|
||||
type PaperOrderStatus string
|
||||
|
||||
const (
|
||||
PaperOrderStatusPending PaperOrderStatus = "pending"
|
||||
PaperOrderStatusFilled PaperOrderStatus = "filled"
|
||||
PaperOrderStatusCanceled PaperOrderStatus = "canceled"
|
||||
PaperOrderStatusRejected PaperOrderStatus = "rejected"
|
||||
)
|
||||
|
||||
// PaperOrder is a single virtual order tracked in memory against an account. It
|
||||
// is paper-only: no real broker order is created, and fills are simulated
|
||||
// against the account's in-memory portfolio.
|
||||
type PaperOrder struct {
|
||||
OrderID string
|
||||
AccountID backtest.PaperAccountID
|
||||
Intent backtest.OrderIntent
|
||||
Status PaperOrderStatus
|
||||
// Reason carries the risk/portfolio denial cause when Status is rejected.
|
||||
Reason string
|
||||
// Fill is set when Status is filled.
|
||||
Fill *backtest.Fill
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SubmitOrderRequest carries a validated order intent to record against an
|
||||
// account. The socket layer maps the proto request onto this shape so the
|
||||
// service stays decoupled from the wire contract.
|
||||
type SubmitOrderRequest struct {
|
||||
AccountID backtest.PaperAccountID
|
||||
Intent backtest.OrderIntent
|
||||
}
|
||||
|
||||
// orderBook is the per-account, in-memory lifecycle state. It holds a live
|
||||
// portfolio that manual fills mutate, the seed run state for the unchanged
|
||||
// fields (equity curve, run, risk rejections), the manual fills applied so far,
|
||||
// and the recorded orders keyed by order id.
|
||||
type orderBook struct {
|
||||
seed State
|
||||
portfolio backtest.PortfolioState
|
||||
risk backtest.RiskSettings
|
||||
manualFills []backtest.Fill
|
||||
seq int
|
||||
orders map[string]*PaperOrder
|
||||
}
|
||||
|
||||
// snapshot renders the live order book as an inspectable State. Cash, positions
|
||||
// and the cumulative fill list reflect manual fills, while equity curve and risk
|
||||
// rejections are carried unchanged from the seed run.
|
||||
func (b *orderBook) snapshot() State {
|
||||
positions := make([]backtest.Position, 0, len(b.portfolio.Positions))
|
||||
for _, p := range b.portfolio.Positions {
|
||||
positions = append(positions, p)
|
||||
}
|
||||
sort.Slice(positions, func(i, j int) bool {
|
||||
return positions[i].InstrumentID < positions[j].InstrumentID
|
||||
})
|
||||
fills := make([]backtest.Fill, 0, len(b.seed.Fills)+len(b.manualFills))
|
||||
fills = append(fills, b.seed.Fills...)
|
||||
fills = append(fills, b.manualFills...)
|
||||
return State{
|
||||
Run: b.seed.Run,
|
||||
Cash: b.portfolio.Cash,
|
||||
Positions: positions,
|
||||
Fills: fills,
|
||||
EquityCurve: b.seed.EquityCurve,
|
||||
Rejected: b.seed.Rejected,
|
||||
}
|
||||
}
|
||||
|
||||
// orderBookLocked returns the account's order book, seeding it from the recorded
|
||||
// run state on first access. It must be called with s.mu held.
|
||||
func (s *Service) orderBookLocked(accountID backtest.PaperAccountID) (*orderBook, error) {
|
||||
if book, ok := s.books[accountID]; ok {
|
||||
return book, nil
|
||||
}
|
||||
seed, ok := s.states[accountID]
|
||||
if !ok {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
positions := make(map[market.InstrumentID]backtest.Position, len(seed.Positions))
|
||||
for _, p := range seed.Positions {
|
||||
positions[p.InstrumentID] = p
|
||||
}
|
||||
book := &orderBook{
|
||||
seed: seed,
|
||||
portfolio: backtest.PortfolioState{Cash: seed.Cash, Positions: positions},
|
||||
// Paper risk mirrors StartPaperTrading: short selling stays disabled so a
|
||||
// sell without an open position is blocked rather than silently shorting.
|
||||
risk: backtest.RiskSettings{AllowShortSelling: false},
|
||||
orders: make(map[string]*PaperOrder),
|
||||
}
|
||||
s.books[accountID] = book
|
||||
return book, nil
|
||||
}
|
||||
|
||||
// SubmitPaperOrder records a new pending virtual order for the account. The
|
||||
// account must already have been started; the order book is seeded from its run
|
||||
// state. The intent is expected to be pre-validated by the socket mapping, but a
|
||||
// defensive check keeps the registry from storing a malformed order.
|
||||
func (s *Service) SubmitPaperOrder(ctx context.Context, req SubmitOrderRequest) (PaperOrder, error) {
|
||||
if req.AccountID == "" {
|
||||
return PaperOrder{}, fmt.Errorf("account_id is required")
|
||||
}
|
||||
if err := validateOrderIntent(req.Intent); err != nil {
|
||||
return PaperOrder{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
book, err := s.orderBookLocked(req.AccountID)
|
||||
if err != nil {
|
||||
return PaperOrder{}, err
|
||||
}
|
||||
|
||||
book.seq++
|
||||
now := s.now().UTC()
|
||||
order := &PaperOrder{
|
||||
OrderID: fmt.Sprintf("paper-order-%s-%d", req.AccountID, book.seq),
|
||||
AccountID: req.AccountID,
|
||||
Intent: req.Intent,
|
||||
Status: PaperOrderStatusPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
book.orders[order.OrderID] = order
|
||||
return *order, nil
|
||||
}
|
||||
|
||||
// CancelPaperOrder transitions a pending order to canceled. A missing order is
|
||||
// ErrOrderNotFound; an already-terminal order is ErrOrderNotPending.
|
||||
func (s *Service) CancelPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string) (PaperOrder, error) {
|
||||
if accountID == "" {
|
||||
return PaperOrder{}, fmt.Errorf("account_id is required")
|
||||
}
|
||||
if orderID == "" {
|
||||
return PaperOrder{}, fmt.Errorf("order_id is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
order, err := s.findOrderLocked(accountID, orderID)
|
||||
if err != nil {
|
||||
return PaperOrder{}, err
|
||||
}
|
||||
if order.Status != PaperOrderStatusPending {
|
||||
return PaperOrder{}, ErrOrderNotPending
|
||||
}
|
||||
order.Status = PaperOrderStatusCanceled
|
||||
order.UpdatedAt = s.now().UTC()
|
||||
return *order, nil
|
||||
}
|
||||
|
||||
// FillPaperOrder simulates the execution of a pending order against the account
|
||||
// portfolio and returns the resulting order plus the post-fill account state.
|
||||
//
|
||||
// Request-level problems (missing account/order, non-pending order, missing
|
||||
// market fill price) return a typed error. Business denials (risk gate or
|
||||
// insufficient cash/position) are not errors: the order transitions to rejected
|
||||
// with a reason so the operator sees the status change, and the returned state
|
||||
// is the unchanged account snapshot.
|
||||
func (s *Service) FillPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string, fillPrice market.Price) (PaperOrder, State, error) {
|
||||
if accountID == "" {
|
||||
return PaperOrder{}, State{}, fmt.Errorf("account_id is required")
|
||||
}
|
||||
if orderID == "" {
|
||||
return PaperOrder{}, State{}, fmt.Errorf("order_id is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
book, ok := s.books[accountID]
|
||||
if !ok {
|
||||
// No order book means the account was never started or never received an
|
||||
// order; either way the order id cannot exist.
|
||||
if _, started := s.states[accountID]; !started {
|
||||
return PaperOrder{}, State{}, ErrAccountNotFound
|
||||
}
|
||||
return PaperOrder{}, State{}, ErrOrderNotFound
|
||||
}
|
||||
order, ok := book.orders[orderID]
|
||||
if !ok {
|
||||
return PaperOrder{}, State{}, ErrOrderNotFound
|
||||
}
|
||||
if order.Status != PaperOrderStatusPending {
|
||||
return PaperOrder{}, State{}, ErrOrderNotPending
|
||||
}
|
||||
|
||||
// A supplied fill price must be a non-negative number before it can drive a
|
||||
// fill; reject malformed/negative input as invalid request, not an internal
|
||||
// fill error. An empty fill price is handled per order type in syntheticFillBar.
|
||||
if fillPrice.Amount.Value != "" {
|
||||
if err := ValidateOrderDecimal("fill_price", fillPrice.Amount.Value, false); err != nil {
|
||||
return PaperOrder{}, State{}, err
|
||||
}
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
|
||||
// Resolve the execution bar before any portfolio change so a missing market
|
||||
// price is reported as a request error, not an order rejection.
|
||||
bar, err := syntheticFillBar(order.Intent, fillPrice, now)
|
||||
if err != nil {
|
||||
return PaperOrder{}, State{}, err
|
||||
}
|
||||
|
||||
account := backtest.PaperAccount{
|
||||
ID: accountID,
|
||||
Portfolio: book.portfolio,
|
||||
RiskSettings: book.risk,
|
||||
}
|
||||
if decision := backtest.CheckRisk(account, order.Intent); !decision.Allowed {
|
||||
order.Status = PaperOrderStatusRejected
|
||||
order.Reason = decision.Reason
|
||||
order.UpdatedAt = now
|
||||
return *order, book.snapshot(), nil
|
||||
}
|
||||
|
||||
fill, filled, err := backtest.FillOrderOnDailyBar(order.Intent, bar)
|
||||
if err != nil {
|
||||
return PaperOrder{}, State{}, err
|
||||
}
|
||||
if !filled {
|
||||
// With a synthetic single-price bar a market order always fills and a limit
|
||||
// order fills at its own price, so this is unreachable in practice. Treat it
|
||||
// as a rejection rather than silently leaving the order pending.
|
||||
order.Status = PaperOrderStatusRejected
|
||||
order.Reason = "order did not fill at the simulated price"
|
||||
order.UpdatedAt = now
|
||||
return *order, book.snapshot(), nil
|
||||
}
|
||||
|
||||
next, err := book.portfolio.ApplyFill(fill)
|
||||
if err != nil {
|
||||
order.Status = PaperOrderStatusRejected
|
||||
order.Reason = err.Error()
|
||||
order.UpdatedAt = now
|
||||
return *order, book.snapshot(), nil
|
||||
}
|
||||
|
||||
book.portfolio = next
|
||||
book.manualFills = append(book.manualFills, fill)
|
||||
filledCopy := fill
|
||||
order.Status = PaperOrderStatusFilled
|
||||
order.Fill = &filledCopy
|
||||
order.UpdatedAt = now
|
||||
return *order, book.snapshot(), nil
|
||||
}
|
||||
|
||||
// findOrderLocked resolves an order id within an account, distinguishing a
|
||||
// never-started account from a missing order. It must be called with s.mu held.
|
||||
func (s *Service) findOrderLocked(accountID backtest.PaperAccountID, orderID string) (*PaperOrder, error) {
|
||||
book, ok := s.books[accountID]
|
||||
if !ok {
|
||||
if _, started := s.states[accountID]; !started {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
order, ok := book.orders[orderID]
|
||||
if !ok {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// ValidateOrderDecimal checks that an order lifecycle decimal string parses as a
|
||||
// number and meets its sign requirement, returning an error wrapping
|
||||
// ErrInvalidOrderInput so the worker socket boundary maps malformed input to
|
||||
// invalid_request. requirePositive enforces strictly positive (quantities);
|
||||
// otherwise the value must be non-negative (prices). It is exported so the socket
|
||||
// conversion can reject malformed input before building a domain order, while the
|
||||
// service stays defensive for direct package callers.
|
||||
func ValidateOrderDecimal(label, value string, requirePositive bool) error {
|
||||
rat, ok := new(big.Rat).SetString(strings.TrimSpace(value))
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s must be a number", ErrInvalidOrderInput, label)
|
||||
}
|
||||
if requirePositive {
|
||||
if rat.Sign() <= 0 {
|
||||
return fmt.Errorf("%w: %s must be greater than zero", ErrInvalidOrderInput, label)
|
||||
}
|
||||
} else if rat.Sign() < 0 {
|
||||
return fmt.Errorf("%w: %s must not be negative", ErrInvalidOrderInput, label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOrderIntent enforces the minimum shape of an order intent: an
|
||||
// instrument, a known side, a positive numeric quantity, a known type, and a
|
||||
// non-negative numeric limit price for limit orders.
|
||||
func validateOrderIntent(intent backtest.OrderIntent) error {
|
||||
if intent.InstrumentID == "" {
|
||||
return fmt.Errorf("%w: instrument_id is required", ErrInvalidOrderInput)
|
||||
}
|
||||
switch intent.Side {
|
||||
case backtest.OrderSideBuy, backtest.OrderSideSell:
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported order side %q", ErrInvalidOrderInput, intent.Side)
|
||||
}
|
||||
if intent.Quantity.Amount.Value == "" {
|
||||
return fmt.Errorf("%w: quantity is required", ErrInvalidOrderInput)
|
||||
}
|
||||
if err := ValidateOrderDecimal("quantity", intent.Quantity.Amount.Value, true); err != nil {
|
||||
return err
|
||||
}
|
||||
switch intent.OrderType() {
|
||||
case backtest.OrderTypeMarket:
|
||||
case backtest.OrderTypeLimit:
|
||||
if intent.LimitPrice.Currency == "" || intent.LimitPrice.Amount.Value == "" {
|
||||
return fmt.Errorf("%w: limit order requires a limit price", ErrInvalidOrderInput)
|
||||
}
|
||||
if err := ValidateOrderDecimal("limit_price", intent.LimitPrice.Amount.Value, false); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported order type %q", ErrInvalidOrderInput, intent.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syntheticFillBar builds the single-bar input for FillOrderOnDailyBar so the
|
||||
// command-driven fill reuses the same domain fill helper as the run loop. A
|
||||
// market order fills at the operator-supplied price; a limit order fills at its
|
||||
// own limit price (the operator is asserting the market reached it).
|
||||
func syntheticFillBar(intent backtest.OrderIntent, fillPrice market.Price, ts time.Time) (market.Bar, error) {
|
||||
var price market.Price
|
||||
switch intent.OrderType() {
|
||||
case backtest.OrderTypeMarket:
|
||||
if fillPrice.Currency == "" || fillPrice.Amount.Value == "" {
|
||||
return market.Bar{}, ErrFillPriceRequired
|
||||
}
|
||||
price = fillPrice
|
||||
case backtest.OrderTypeLimit:
|
||||
price = intent.LimitPrice
|
||||
default:
|
||||
return market.Bar{}, fmt.Errorf("unsupported order type %q", intent.Type)
|
||||
}
|
||||
return market.Bar{
|
||||
InstrumentID: intent.InstrumentID,
|
||||
Timeframe: market.TimeframeDaily,
|
||||
Timestamp: ts,
|
||||
Open: price,
|
||||
High: price,
|
||||
Low: price,
|
||||
Close: price,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,3 +87,346 @@ func TestServiceStartRequiresAccountID(t *testing.T) {
|
|||
t.Fatal("expected error for empty account id")
|
||||
}
|
||||
}
|
||||
|
||||
// --- order lifecycle ---
|
||||
|
||||
func krwPrice(v string) market.Price {
|
||||
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
|
||||
}
|
||||
|
||||
func qty(v string) market.Quantity {
|
||||
return market.Quantity{Amount: market.Decimal{Value: v}}
|
||||
}
|
||||
|
||||
// startedService starts the standard test account so order lifecycle tests run
|
||||
// against a seeded order book (cash 9998950, 1 unit of KRX:005930).
|
||||
func startedService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
svc := newTestService()
|
||||
if _, err := svc.StartPaperTrading(context.Background(), serviceTestRequest()); err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func buyMarketIntent(qtyVal string) backtest.OrderIntent {
|
||||
return backtest.OrderIntent{
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: backtest.OrderSideBuy,
|
||||
Quantity: qty(qtyVal),
|
||||
Type: backtest.OrderTypeMarket,
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSubmitOrderRecordsPending(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{
|
||||
AccountID: "paper-acct-1",
|
||||
Intent: buyMarketIntent("1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
if order.Status != PaperOrderStatusPending {
|
||||
t.Errorf("expected pending status, got %q", order.Status)
|
||||
}
|
||||
if order.OrderID != "paper-order-paper-acct-1-1" {
|
||||
t.Errorf("unexpected order id %q", order.OrderID)
|
||||
}
|
||||
if order.Fill != nil {
|
||||
t.Errorf("pending order must not carry a fill: %+v", order.Fill)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSubmitOrderRequiresStartedAccount(t *testing.T) {
|
||||
svc := newTestService()
|
||||
_, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{
|
||||
AccountID: "never-started",
|
||||
Intent: buyMarketIntent("1"),
|
||||
})
|
||||
if !errors.Is(err, ErrAccountNotFound) {
|
||||
t.Fatalf("expected ErrAccountNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSubmitOrderRejectsInvalidIntent(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
bad := buyMarketIntent("1")
|
||||
bad.Side = "hold"
|
||||
if _, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: bad}); err == nil {
|
||||
t.Fatal("expected error for unsupported side")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFillMarketOrderUpdatesPortfolio(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
|
||||
filled, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000"))
|
||||
if err != nil {
|
||||
t.Fatalf("fill failed: %v", err)
|
||||
}
|
||||
if filled.Status != PaperOrderStatusFilled {
|
||||
t.Fatalf("expected filled status, got %q (reason %q)", filled.Status, filled.Reason)
|
||||
}
|
||||
if filled.Fill == nil || filled.Fill.Price.Amount.Value != "1000" {
|
||||
t.Fatalf("expected fill at 1000, got %+v", filled.Fill)
|
||||
}
|
||||
// Buy 1 @ 1000 against seed cash 9998950 -> 9997950, position 1 + 1 = 2.
|
||||
if state.Cash.Amount.Value != "9997950" {
|
||||
t.Errorf("expected cash 9997950, got %s", state.Cash.Amount.Value)
|
||||
}
|
||||
if len(state.Positions) != 1 || state.Positions[0].Quantity.Amount.Value != "2" {
|
||||
t.Errorf("expected position qty 2, got %+v", state.Positions)
|
||||
}
|
||||
// Seed run fill (1) plus the manual fill (1).
|
||||
if len(state.Fills) != 2 {
|
||||
t.Errorf("expected 2 cumulative fills, got %d", len(state.Fills))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFillLimitOrderUsesLimitPrice(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
|
||||
intent := backtest.OrderIntent{
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: backtest.OrderSideBuy,
|
||||
Quantity: qty("1"),
|
||||
Type: backtest.OrderTypeLimit,
|
||||
LimitPrice: krwPrice("900"),
|
||||
}
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: intent})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
|
||||
// A limit order executes at its own limit price; fill_price is irrelevant.
|
||||
filled, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, market.Price{})
|
||||
if err != nil {
|
||||
t.Fatalf("fill failed: %v", err)
|
||||
}
|
||||
if filled.Status != PaperOrderStatusFilled || filled.Fill.Price.Amount.Value != "900" {
|
||||
t.Fatalf("expected fill at limit 900, got status %q fill %+v", filled.Status, filled.Fill)
|
||||
}
|
||||
if state.Cash.Amount.Value != "9998050" {
|
||||
t.Errorf("expected cash 9998050, got %s", state.Cash.Amount.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFillMarketOrderRequiresFillPrice(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
_, _, err = svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, market.Price{})
|
||||
if !errors.Is(err, ErrFillPriceRequired) {
|
||||
t.Fatalf("expected ErrFillPriceRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFillRejectedByRisk(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
// Sell 5 units while the seeded position holds only 1; short selling disabled.
|
||||
intent := backtest.OrderIntent{
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: backtest.OrderSideSell,
|
||||
Quantity: qty("5"),
|
||||
Type: backtest.OrderTypeMarket,
|
||||
}
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: intent})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
rejected, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1200"))
|
||||
if err != nil {
|
||||
t.Fatalf("fill should not error on a risk denial: %v", err)
|
||||
}
|
||||
if rejected.Status != PaperOrderStatusRejected {
|
||||
t.Fatalf("expected rejected status, got %q", rejected.Status)
|
||||
}
|
||||
if rejected.Reason == "" {
|
||||
t.Error("expected a non-empty rejection reason")
|
||||
}
|
||||
// Risk denial leaves the portfolio untouched.
|
||||
if state.Cash.Amount.Value != "9998950" {
|
||||
t.Errorf("expected cash unchanged at 9998950, got %s", state.Cash.Amount.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFillRejectedByInsufficientCash(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("100000")})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
rejected, state, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000"))
|
||||
if err != nil {
|
||||
t.Fatalf("fill should not error on a portfolio denial: %v", err)
|
||||
}
|
||||
if rejected.Status != PaperOrderStatusRejected {
|
||||
t.Fatalf("expected rejected status, got %q", rejected.Status)
|
||||
}
|
||||
if state.Cash.Amount.Value != "9998950" {
|
||||
t.Errorf("expected cash unchanged at 9998950, got %s", state.Cash.Amount.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCancelTransitionsPendingOrder(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
canceled, err := svc.CancelPaperOrder(context.Background(), "paper-acct-1", order.OrderID)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel failed: %v", err)
|
||||
}
|
||||
if canceled.Status != PaperOrderStatusCanceled {
|
||||
t.Errorf("expected canceled status, got %q", canceled.Status)
|
||||
}
|
||||
|
||||
// Filling a canceled order is an invalid transition.
|
||||
if _, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000")); !errors.Is(err, ErrOrderNotPending) {
|
||||
t.Fatalf("expected ErrOrderNotPending, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCancelMissingOrder(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
// Started account but no such order id.
|
||||
if _, err := svc.CancelPaperOrder(context.Background(), "paper-acct-1", "paper-order-paper-acct-1-99"); !errors.Is(err, ErrOrderNotFound) {
|
||||
t.Fatalf("expected ErrOrderNotFound, got %v", err)
|
||||
}
|
||||
// Never-started account.
|
||||
if _, err := svc.CancelPaperOrder(context.Background(), "ghost", "any"); !errors.Is(err, ErrAccountNotFound) {
|
||||
t.Fatalf("expected ErrAccountNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGetStateReflectsOrderBook(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
if _, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000")); err != nil {
|
||||
t.Fatalf("fill failed: %v", err)
|
||||
}
|
||||
|
||||
// After a manual fill, the account view reflects the live order book, not the
|
||||
// stale terminal run state.
|
||||
state, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get state failed: %v", err)
|
||||
}
|
||||
if state.Cash.Amount.Value != "9997950" {
|
||||
t.Errorf("expected live cash 9997950, got %s", state.Cash.Amount.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceStartClearsOrderBook is the restart regression: a second start of
|
||||
// the same account must discard the prior order book so state and order ids are
|
||||
// seeded from the new run, not the stale manual portfolio/sequence.
|
||||
func TestServiceStartClearsOrderBook(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
if order.OrderID != "paper-order-paper-acct-1-1" {
|
||||
t.Fatalf("unexpected first order id %q", order.OrderID)
|
||||
}
|
||||
if _, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000")); err != nil {
|
||||
t.Fatalf("fill failed: %v", err)
|
||||
}
|
||||
|
||||
// Pre-restart the live book is exposed (cash mutated by the manual fill).
|
||||
pre, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
|
||||
if err != nil {
|
||||
t.Fatalf("pre-restart state failed: %v", err)
|
||||
}
|
||||
if pre.Cash.Amount.Value != "9997950" {
|
||||
t.Fatalf("expected pre-restart live cash 9997950, got %s", pre.Cash.Amount.Value)
|
||||
}
|
||||
|
||||
// Restart the same account.
|
||||
if _, err := svc.StartPaperTrading(context.Background(), serviceTestRequest()); err != nil {
|
||||
t.Fatalf("restart failed: %v", err)
|
||||
}
|
||||
|
||||
// State must now reflect the fresh run seed, not the stale order book.
|
||||
post, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
|
||||
if err != nil {
|
||||
t.Fatalf("post-restart state failed: %v", err)
|
||||
}
|
||||
if post.Cash.Amount.Value != "9998950" {
|
||||
t.Errorf("expected post-restart run-seed cash 9998950, got %s", post.Cash.Amount.Value)
|
||||
}
|
||||
|
||||
// The next order starts from a fresh book sequence.
|
||||
next, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
|
||||
if err != nil {
|
||||
t.Fatalf("post-restart submit failed: %v", err)
|
||||
}
|
||||
if next.OrderID != "paper-order-paper-acct-1-1" {
|
||||
t.Errorf("expected fresh sequence paper-order-paper-acct-1-1, got %q", next.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSubmitRejectsInvalidQuantity(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
for _, q := range []string{"0", "-1", "abc", " "} {
|
||||
_, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent(q)})
|
||||
if !errors.Is(err, ErrInvalidOrderInput) {
|
||||
t.Errorf("quantity %q: expected ErrInvalidOrderInput, got %v", q, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSubmitRejectsInvalidLimitPrice(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
for _, p := range []string{"-1", "abc"} {
|
||||
intent := backtest.OrderIntent{
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: backtest.OrderSideBuy,
|
||||
Quantity: qty("1"),
|
||||
Type: backtest.OrderTypeLimit,
|
||||
LimitPrice: krwPrice(p),
|
||||
}
|
||||
_, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: intent})
|
||||
if !errors.Is(err, ErrInvalidOrderInput) {
|
||||
t.Errorf("limit price %q: expected ErrInvalidOrderInput, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceFillRejectsInvalidFillPrice(t *testing.T) {
|
||||
svc := startedService(t)
|
||||
order, err := svc.SubmitPaperOrder(context.Background(), SubmitOrderRequest{AccountID: "paper-acct-1", Intent: buyMarketIntent("1")})
|
||||
if err != nil {
|
||||
t.Fatalf("submit failed: %v", err)
|
||||
}
|
||||
for _, p := range []string{"-1", "abc"} {
|
||||
// A malformed/negative fill price is rejected before any portfolio change,
|
||||
// so the order stays pending and can be retried with a valid price.
|
||||
_, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice(p))
|
||||
if !errors.Is(err, ErrInvalidOrderInput) {
|
||||
t.Errorf("fill price %q: expected ErrInvalidOrderInput, got %v", p, err)
|
||||
}
|
||||
}
|
||||
// A valid fill still succeeds afterwards (order was left pending).
|
||||
filled, _, err := svc.FillPaperOrder(context.Background(), "paper-acct-1", order.OrderID, krwPrice("1000"))
|
||||
if err != nil {
|
||||
t.Fatalf("valid fill failed: %v", err)
|
||||
}
|
||||
if filled.Status != PaperOrderStatusFilled {
|
||||
t.Errorf("expected filled after valid retry, got %q", filled.Status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
|
||||
"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/papertrading"
|
||||
)
|
||||
|
||||
|
|
@ -17,6 +18,12 @@ import (
|
|||
type PaperService interface {
|
||||
StartPaperTrading(ctx context.Context, req papertrading.StartRequest) (papertrading.State, error)
|
||||
GetPaperTradingState(ctx context.Context, accountID backtest.PaperAccountID) (papertrading.State, error)
|
||||
|
||||
// Virtual order lifecycle: submit records a pending order, cancel transitions
|
||||
// a pending order, fill simulates execution against the account portfolio.
|
||||
SubmitPaperOrder(ctx context.Context, req papertrading.SubmitOrderRequest) (papertrading.PaperOrder, error)
|
||||
CancelPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string) (papertrading.PaperOrder, error)
|
||||
FillPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string, fillPrice market.Price) (papertrading.PaperOrder, papertrading.State, error)
|
||||
}
|
||||
|
||||
// paperHandlers returns the session handlers for the paper trading command and
|
||||
|
|
@ -45,6 +52,39 @@ func paperHandlers(deps Deps) []sessionHandler {
|
|||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
requestType: protoSocket.TypeNameOf(&altv1.SubmitPaperOrderRequest{}),
|
||||
register: func(client *protoSocket.WsClient) {
|
||||
protoSocket.AddRequestListenerTyped[*altv1.SubmitPaperOrderRequest, *altv1.SubmitPaperOrderResponse](
|
||||
&client.Communicator,
|
||||
func(req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
return handleSubmitPaperOrder(deps, req)
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
requestType: protoSocket.TypeNameOf(&altv1.CancelPaperOrderRequest{}),
|
||||
register: func(client *protoSocket.WsClient) {
|
||||
protoSocket.AddRequestListenerTyped[*altv1.CancelPaperOrderRequest, *altv1.CancelPaperOrderResponse](
|
||||
&client.Communicator,
|
||||
func(req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
return handleCancelPaperOrder(deps, req)
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
requestType: protoSocket.TypeNameOf(&altv1.FillPaperOrderRequest{}),
|
||||
register: func(client *protoSocket.WsClient) {
|
||||
protoSocket.AddRequestListenerTyped[*altv1.FillPaperOrderRequest, *altv1.FillPaperOrderResponse](
|
||||
&client.Communicator,
|
||||
func(req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
return handleFillPaperOrder(deps, req)
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +126,82 @@ func handleGetPaperTradingState(deps Deps, req *altv1.GetPaperTradingStateReques
|
|||
return &altv1.GetPaperTradingStateResponse{State: paperStateToProto(accountID, state)}, nil
|
||||
}
|
||||
|
||||
func handleSubmitPaperOrder(deps Deps, req *altv1.SubmitPaperOrderRequest) (*altv1.SubmitPaperOrderResponse, error) {
|
||||
submitReq, err := submitPaperOrderRequestFromProto(req)
|
||||
if err != nil {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: invalidPaperRequest(err.Error())}, nil
|
||||
}
|
||||
if deps.Paper == nil {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: unavailableError("paper trading is not available")}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
||||
defer cancel()
|
||||
|
||||
order, err := deps.Paper.SubmitPaperOrder(ctx, submitReq)
|
||||
if err != nil {
|
||||
return &altv1.SubmitPaperOrderResponse{Error: paperBackendErrorInfo(err)}, nil
|
||||
}
|
||||
return &altv1.SubmitPaperOrderResponse{Order: paperOrderToProto(order)}, nil
|
||||
}
|
||||
|
||||
func handleCancelPaperOrder(deps Deps, req *altv1.CancelPaperOrderRequest) (*altv1.CancelPaperOrderResponse, error) {
|
||||
if req.GetAccountId() == "" {
|
||||
return &altv1.CancelPaperOrderResponse{Error: invalidPaperRequest("account_id is required")}, nil
|
||||
}
|
||||
if req.GetOrderId() == "" {
|
||||
return &altv1.CancelPaperOrderResponse{Error: invalidPaperRequest("order_id is required")}, nil
|
||||
}
|
||||
if deps.Paper == nil {
|
||||
return &altv1.CancelPaperOrderResponse{Error: unavailableError("paper trading is not available")}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
||||
defer cancel()
|
||||
|
||||
order, err := deps.Paper.CancelPaperOrder(ctx, backtest.PaperAccountID(req.GetAccountId()), req.GetOrderId())
|
||||
if err != nil {
|
||||
return &altv1.CancelPaperOrderResponse{Error: paperBackendErrorInfo(err)}, nil
|
||||
}
|
||||
return &altv1.CancelPaperOrderResponse{Order: paperOrderToProto(order)}, nil
|
||||
}
|
||||
|
||||
func handleFillPaperOrder(deps Deps, req *altv1.FillPaperOrderRequest) (*altv1.FillPaperOrderResponse, error) {
|
||||
if req.GetAccountId() == "" {
|
||||
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest("account_id is required")}, nil
|
||||
}
|
||||
if req.GetOrderId() == "" {
|
||||
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest("order_id is required")}, nil
|
||||
}
|
||||
var fillPrice market.Price
|
||||
if req.GetFillPrice() != nil {
|
||||
price, err := priceFromProto(req.GetFillPrice())
|
||||
if err != nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest("fill_price: " + err.Error())}, nil
|
||||
}
|
||||
if err := papertrading.ValidateOrderDecimal("fill_price", price.Amount.Value, false); err != nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: invalidPaperRequest(err.Error())}, nil
|
||||
}
|
||||
fillPrice = price
|
||||
}
|
||||
if deps.Paper == nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: unavailableError("paper trading is not available")}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
|
||||
defer cancel()
|
||||
|
||||
accountID := backtest.PaperAccountID(req.GetAccountId())
|
||||
order, state, err := deps.Paper.FillPaperOrder(ctx, accountID, req.GetOrderId(), fillPrice)
|
||||
if err != nil {
|
||||
return &altv1.FillPaperOrderResponse{Error: paperBackendErrorInfo(err)}, nil
|
||||
}
|
||||
return &altv1.FillPaperOrderResponse{
|
||||
Order: paperOrderToProto(order),
|
||||
State: paperStateToProto(accountID, state),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func invalidPaperRequest(reason string) *altv1.ErrorInfo {
|
||||
return errorInfo(backtestErrorInvalidRequest, "invalid paper trading request: "+reason)
|
||||
}
|
||||
|
|
@ -98,8 +214,10 @@ func paperBackendErrorInfo(err error) *altv1.ErrorInfo {
|
|||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, papertrading.ErrAccountNotFound):
|
||||
case errors.Is(err, papertrading.ErrAccountNotFound), errors.Is(err, papertrading.ErrOrderNotFound):
|
||||
return errorInfo(backtestErrorNotFound, err.Error())
|
||||
case errors.Is(err, papertrading.ErrOrderNotPending), errors.Is(err, papertrading.ErrFillPriceRequired), errors.Is(err, papertrading.ErrInvalidOrderInput):
|
||||
return errorInfo(backtestErrorInvalidRequest, err.Error())
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return errorInfo(backtestErrorTimeout, err.Error())
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -70,15 +70,37 @@ func currencyFromProto(c altv1.Currency) (market.Currency, error) {
|
|||
// reusing the shared backtest run/position/trade/equity payload shapes.
|
||||
func paperStateToProto(accountID backtest.PaperAccountID, state papertrading.State) *altv1.PaperTradingState {
|
||||
return &altv1.PaperTradingState{
|
||||
AccountId: string(accountID),
|
||||
Run: runToProto(state.Run),
|
||||
Cash: priceToProto(state.Cash),
|
||||
Positions: paperPositionsToProto(state.Positions),
|
||||
Fills: fillsToProto(state.Fills),
|
||||
EquityCurve: equityCurveToProto(state.EquityCurve),
|
||||
AccountId: string(accountID),
|
||||
Run: runToProto(state.Run),
|
||||
Cash: priceToProto(state.Cash),
|
||||
Positions: paperPositionsToProto(state.Positions),
|
||||
Fills: fillsToProto(state.Fills),
|
||||
EquityCurve: equityCurveToProto(state.EquityCurve),
|
||||
RiskRejections: paperRiskRejectionsToProto(state.Rejected),
|
||||
}
|
||||
}
|
||||
|
||||
// paperRiskRejectionsToProto converts the engine's rejected orders into the
|
||||
// contract risk rejection summary. It uses the rejection's resolved Instrument
|
||||
// (always populated, even for the empty-order-id strategy bug case) so the
|
||||
// operator surface can distinguish a clear run from a blocked one.
|
||||
func paperRiskRejectionsToProto(rejected []papertrading.RejectedOrder) []*altv1.PaperRiskRejection {
|
||||
if len(rejected) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*altv1.PaperRiskRejection, len(rejected))
|
||||
for i, r := range rejected {
|
||||
out[i] = &altv1.PaperRiskRejection{
|
||||
InstrumentId: string(r.Instrument),
|
||||
Side: string(r.Order.Side),
|
||||
Quantity: quantityToProto(r.Order.Quantity),
|
||||
Reason: r.Reason,
|
||||
TimestampUnixMs: timeToUnixMs(r.BarTime),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func paperPositionsToProto(positions []backtest.Position) []*altv1.BacktestPosition {
|
||||
if len(positions) == 0 {
|
||||
return nil
|
||||
|
|
@ -100,13 +122,133 @@ func fillsToProto(fills []backtest.Fill) []*altv1.BacktestTrade {
|
|||
}
|
||||
out := make([]*altv1.BacktestTrade, len(fills))
|
||||
for i, f := range fills {
|
||||
out[i] = &altv1.BacktestTrade{
|
||||
InstrumentId: string(f.InstrumentID),
|
||||
Side: string(f.Side),
|
||||
Quantity: quantityToProto(f.Quantity),
|
||||
Price: priceToProto(f.Price),
|
||||
TimestampUnixMs: timeToUnixMs(f.Timestamp),
|
||||
}
|
||||
out[i] = fillToProto(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillToProto renders a single domain Fill as the shared BacktestTrade payload,
|
||||
// reused for paper fills and for a filled order's fill summary.
|
||||
func fillToProto(f backtest.Fill) *altv1.BacktestTrade {
|
||||
return &altv1.BacktestTrade{
|
||||
InstrumentId: string(f.InstrumentID),
|
||||
Side: string(f.Side),
|
||||
Quantity: quantityToProto(f.Quantity),
|
||||
Price: priceToProto(f.Price),
|
||||
TimestampUnixMs: timeToUnixMs(f.Timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
// submitPaperOrderRequestFromProto validates an inbound SubmitPaperOrderRequest
|
||||
// and converts it into the paper service submit request. Validation lives next
|
||||
// to the conversion so a malformed order never reaches the runtime.
|
||||
func submitPaperOrderRequestFromProto(req *altv1.SubmitPaperOrderRequest) (papertrading.SubmitOrderRequest, error) {
|
||||
if req.GetAccountId() == "" {
|
||||
return papertrading.SubmitOrderRequest{}, fmt.Errorf("account_id is required")
|
||||
}
|
||||
intent, err := orderIntentFromProto(req.GetInstrumentId(), req.GetSide(), req.GetQuantity(), req.GetType(), req.GetLimitPrice())
|
||||
if err != nil {
|
||||
return papertrading.SubmitOrderRequest{}, err
|
||||
}
|
||||
return papertrading.SubmitOrderRequest{
|
||||
AccountID: backtest.PaperAccountID(req.GetAccountId()),
|
||||
Intent: intent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// orderIntentFromProto converts the wire order fields into a domain OrderIntent.
|
||||
// A limit order requires a limit price; a market order ignores it.
|
||||
func orderIntentFromProto(instrumentID, side string, quantity *altv1.Quantity, orderType string, limitPrice *altv1.Price) (backtest.OrderIntent, error) {
|
||||
if instrumentID == "" {
|
||||
return backtest.OrderIntent{}, fmt.Errorf("instrument_id is required")
|
||||
}
|
||||
domainSide, err := orderSideFromProto(side)
|
||||
if err != nil {
|
||||
return backtest.OrderIntent{}, err
|
||||
}
|
||||
domainQuantity, err := quantityFromProto(quantity)
|
||||
if err != nil {
|
||||
return backtest.OrderIntent{}, err
|
||||
}
|
||||
domainType, err := orderTypeFromProto(orderType)
|
||||
if err != nil {
|
||||
return backtest.OrderIntent{}, err
|
||||
}
|
||||
intent := backtest.OrderIntent{
|
||||
InstrumentID: market.InstrumentID(instrumentID),
|
||||
Side: domainSide,
|
||||
Quantity: domainQuantity,
|
||||
Type: domainType,
|
||||
}
|
||||
if domainType == backtest.OrderTypeLimit {
|
||||
price, err := priceFromProto(limitPrice)
|
||||
if err != nil {
|
||||
return backtest.OrderIntent{}, fmt.Errorf("limit_price: %w", err)
|
||||
}
|
||||
if err := papertrading.ValidateOrderDecimal("limit_price", price.Amount.Value, false); err != nil {
|
||||
return backtest.OrderIntent{}, err
|
||||
}
|
||||
intent.LimitPrice = price
|
||||
}
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func orderSideFromProto(side string) (backtest.OrderSide, error) {
|
||||
switch side {
|
||||
case string(backtest.OrderSideBuy):
|
||||
return backtest.OrderSideBuy, nil
|
||||
case string(backtest.OrderSideSell):
|
||||
return backtest.OrderSideSell, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported order side %q (want \"buy\" or \"sell\")", side)
|
||||
}
|
||||
}
|
||||
|
||||
// orderTypeFromProto maps the wire type onto the domain order type. An empty
|
||||
// type defaults to market, mirroring OrderIntent.OrderType.
|
||||
func orderTypeFromProto(orderType string) (backtest.OrderType, error) {
|
||||
switch orderType {
|
||||
case "", string(backtest.OrderTypeMarket):
|
||||
return backtest.OrderTypeMarket, nil
|
||||
case string(backtest.OrderTypeLimit):
|
||||
return backtest.OrderTypeLimit, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported order type %q (want \"market\" or \"limit\")", orderType)
|
||||
}
|
||||
}
|
||||
|
||||
func quantityFromProto(quantity *altv1.Quantity) (market.Quantity, error) {
|
||||
if quantity == nil || quantity.GetAmount().GetValue() == "" {
|
||||
return market.Quantity{}, fmt.Errorf("quantity is required")
|
||||
}
|
||||
value := quantity.GetAmount().GetValue()
|
||||
if err := papertrading.ValidateOrderDecimal("quantity", value, true); err != nil {
|
||||
return market.Quantity{}, err
|
||||
}
|
||||
return market.Quantity{Amount: market.Decimal{Value: value}}, nil
|
||||
}
|
||||
|
||||
// paperOrderToProto renders a lifecycle order onto the contract message. The
|
||||
// limit price is only set for limit orders and the fill summary only for filled
|
||||
// orders, keeping a pending/canceled order's payload minimal.
|
||||
func paperOrderToProto(order papertrading.PaperOrder) *altv1.PaperOrder {
|
||||
out := &altv1.PaperOrder{
|
||||
OrderId: order.OrderID,
|
||||
AccountId: string(order.AccountID),
|
||||
InstrumentId: string(order.Intent.InstrumentID),
|
||||
Side: string(order.Intent.Side),
|
||||
Quantity: quantityToProto(order.Intent.Quantity),
|
||||
Type: string(order.Intent.OrderType()),
|
||||
Status: string(order.Status),
|
||||
Reason: order.Reason,
|
||||
CreatedAtUnixMs: timeToUnixMs(order.CreatedAt),
|
||||
UpdatedAtUnixMs: timeToUnixMs(order.UpdatedAt),
|
||||
}
|
||||
if order.Intent.OrderType() == backtest.OrderTypeLimit {
|
||||
out.LimitPrice = priceToProto(order.Intent.LimitPrice)
|
||||
}
|
||||
if order.Fill != nil {
|
||||
out.Fill = fillToProto(*order.Fill)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,16 @@ type fakePaperService struct {
|
|||
state papertrading.State
|
||||
startErr error
|
||||
stateErr error
|
||||
|
||||
// Order lifecycle captures and canned results.
|
||||
gotSubmit papertrading.SubmitOrderRequest
|
||||
gotCancelOrder string
|
||||
gotFillOrder string
|
||||
gotFillPrice market.Price
|
||||
order papertrading.PaperOrder
|
||||
submitErr error
|
||||
cancelErr error
|
||||
fillErr error
|
||||
}
|
||||
|
||||
func (f *fakePaperService) StartPaperTrading(ctx context.Context, req papertrading.StartRequest) (papertrading.State, error) {
|
||||
|
|
@ -37,6 +47,47 @@ func (f *fakePaperService) GetPaperTradingState(ctx context.Context, accountID b
|
|||
return f.state, nil
|
||||
}
|
||||
|
||||
func (f *fakePaperService) SubmitPaperOrder(ctx context.Context, req papertrading.SubmitOrderRequest) (papertrading.PaperOrder, error) {
|
||||
f.gotSubmit = req
|
||||
if f.submitErr != nil {
|
||||
return papertrading.PaperOrder{}, f.submitErr
|
||||
}
|
||||
return f.order, nil
|
||||
}
|
||||
|
||||
func (f *fakePaperService) CancelPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string) (papertrading.PaperOrder, error) {
|
||||
f.gotAccount = accountID
|
||||
f.gotCancelOrder = orderID
|
||||
if f.cancelErr != nil {
|
||||
return papertrading.PaperOrder{}, f.cancelErr
|
||||
}
|
||||
return f.order, nil
|
||||
}
|
||||
|
||||
func (f *fakePaperService) FillPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string, fillPrice market.Price) (papertrading.PaperOrder, papertrading.State, error) {
|
||||
f.gotAccount = accountID
|
||||
f.gotFillOrder = orderID
|
||||
f.gotFillPrice = fillPrice
|
||||
if f.fillErr != nil {
|
||||
return papertrading.PaperOrder{}, papertrading.State{}, f.fillErr
|
||||
}
|
||||
return f.order, f.state, nil
|
||||
}
|
||||
|
||||
func sampleOrder() papertrading.PaperOrder {
|
||||
return papertrading.PaperOrder{
|
||||
OrderID: "paper-order-paper-1-1",
|
||||
AccountID: "paper-1",
|
||||
Intent: backtest.OrderIntent{
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: backtest.OrderSideBuy,
|
||||
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
||||
Type: backtest.OrderTypeMarket,
|
||||
},
|
||||
Status: papertrading.PaperOrderStatusPending,
|
||||
}
|
||||
}
|
||||
|
||||
func krw(v string) market.Price {
|
||||
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
|
||||
}
|
||||
|
|
@ -75,6 +126,18 @@ func sampleState() papertrading.State {
|
|||
EquityCurve: []backtest.EquityPoint{
|
||||
{Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Equity: krw("10000100")},
|
||||
},
|
||||
Rejected: []papertrading.RejectedOrder{
|
||||
{
|
||||
Order: backtest.OrderIntent{
|
||||
InstrumentID: "KRX:000660",
|
||||
Side: backtest.OrderSideSell,
|
||||
Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}},
|
||||
},
|
||||
Reason: "insufficient position quantity for sell order",
|
||||
BarTime: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
|
||||
Instrument: "KRX:000660",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +171,19 @@ func TestHandleStartPaperTradingSuccess(t *testing.T) {
|
|||
if len(st.GetEquityCurve()) != 1 {
|
||||
t.Errorf("equity curve mismatch: %+v", st.GetEquityCurve())
|
||||
}
|
||||
if len(st.GetRiskRejections()) != 1 {
|
||||
t.Fatalf("risk rejections mismatch: %+v", st.GetRiskRejections())
|
||||
}
|
||||
rej := st.GetRiskRejections()[0]
|
||||
if rej.GetInstrumentId() != "KRX:000660" || rej.GetSide() != "sell" {
|
||||
t.Errorf("rejection instrument/side mismatch: %+v", rej)
|
||||
}
|
||||
if rej.GetReason() != "insufficient position quantity for sell order" {
|
||||
t.Errorf("rejection reason mismatch: %q", rej.GetReason())
|
||||
}
|
||||
if rej.GetQuantity().GetAmount().GetValue() != "2" {
|
||||
t.Errorf("rejection quantity mismatch: %q", rej.GetQuantity().GetAmount().GetValue())
|
||||
}
|
||||
if paper.gotStart.AccountID != "paper-1" || paper.gotStart.StartingCash.Amount.Value != "10000000" {
|
||||
t.Errorf("service received wrong start request: %+v", paper.gotStart)
|
||||
}
|
||||
|
|
@ -199,6 +275,270 @@ func TestHandleGetPaperTradingStateUnavailable(t *testing.T) {
|
|||
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderSuccess(t *testing.T) {
|
||||
paper := &fakePaperService{order: sampleOrder()}
|
||||
deps := Deps{Paper: paper}
|
||||
|
||||
resp, err := handleSubmitPaperOrder(deps, &altv1.SubmitPaperOrderRequest{
|
||||
AccountId: "paper-1",
|
||||
InstrumentId: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
||||
Type: "market",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
||||
}
|
||||
if resp.GetOrder().GetOrderId() != "paper-order-paper-1-1" {
|
||||
t.Errorf("order id mismatch: %q", resp.GetOrder().GetOrderId())
|
||||
}
|
||||
if resp.GetOrder().GetStatus() != "pending" {
|
||||
t.Errorf("expected pending status, got %q", resp.GetOrder().GetStatus())
|
||||
}
|
||||
if paper.gotSubmit.Intent.InstrumentID != "KRX:005930" || paper.gotSubmit.Intent.Side != backtest.OrderSideBuy {
|
||||
t.Errorf("service received wrong intent: %+v", paper.gotSubmit.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderInvalidRequest(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
||||
|
||||
cases := []*altv1.SubmitPaperOrderRequest{
|
||||
{InstrumentId: "KRX:005930", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}}, // missing account
|
||||
{AccountId: "paper-1", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}}, // missing instrument
|
||||
{AccountId: "paper-1", InstrumentId: "KRX:005930", Side: "hold", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}}, // bad side
|
||||
{AccountId: "paper-1", InstrumentId: "KRX:005930", Side: "buy"}, // missing quantity
|
||||
{AccountId: "paper-1", InstrumentId: "KRX:005930", Side: "buy", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}, Type: "limit"}, // limit without price
|
||||
}
|
||||
for i, req := range cases {
|
||||
resp, err := handleSubmitPaperOrder(deps, req)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d: expected typed validation response, got error: %v", i, err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderInvalidDecimal(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
||||
base := func() *altv1.SubmitPaperOrderRequest {
|
||||
return &altv1.SubmitPaperOrderRequest{
|
||||
AccountId: "paper-1",
|
||||
InstrumentId: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
||||
Type: "market",
|
||||
}
|
||||
}
|
||||
|
||||
// Non-numeric, zero, and negative quantities are rejected at the socket
|
||||
// boundary before reaching the service.
|
||||
for _, q := range []string{"0", "-1", "abc"} {
|
||||
req := base()
|
||||
req.Quantity = &altv1.Quantity{Amount: &altv1.Decimal{Value: q}}
|
||||
resp, err := handleSubmitPaperOrder(deps, req)
|
||||
if err != nil {
|
||||
t.Fatalf("quantity %q: unexpected error: %v", q, err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
|
||||
// Malformed and negative limit prices are rejected too.
|
||||
for _, p := range []string{"-1", "abc"} {
|
||||
req := base()
|
||||
req.Type = "limit"
|
||||
req.LimitPrice = &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: p}}
|
||||
resp, err := handleSubmitPaperOrder(deps, req)
|
||||
if err != nil {
|
||||
t.Fatalf("limit price %q: unexpected error: %v", p, err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderInvalidFillPrice(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{order: sampleOrder(), state: sampleState()}}
|
||||
for _, p := range []string{"-1", "abc"} {
|
||||
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{
|
||||
AccountId: "paper-1",
|
||||
OrderId: "paper-order-paper-1-1",
|
||||
FillPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: p}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("fill price %q: unexpected error: %v", p, err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderUnavailable(t *testing.T) {
|
||||
resp, err := handleSubmitPaperOrder(Deps{}, &altv1.SubmitPaperOrderRequest{
|
||||
AccountId: "paper-1",
|
||||
InstrumentId: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed unavailable response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
|
||||
}
|
||||
|
||||
func TestHandleSubmitPaperOrderAccountNotFound(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{submitErr: papertrading.ErrAccountNotFound}}
|
||||
resp, err := handleSubmitPaperOrder(deps, &altv1.SubmitPaperOrderRequest{
|
||||
AccountId: "missing",
|
||||
InstrumentId: "KRX:005930",
|
||||
Side: "buy",
|
||||
Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed not_found response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorNotFound)
|
||||
}
|
||||
|
||||
func TestHandleCancelPaperOrderSuccess(t *testing.T) {
|
||||
order := sampleOrder()
|
||||
order.Status = papertrading.PaperOrderStatusCanceled
|
||||
paper := &fakePaperService{order: order}
|
||||
deps := Deps{Paper: paper}
|
||||
|
||||
resp, err := handleCancelPaperOrder(deps, &altv1.CancelPaperOrderRequest{AccountId: "paper-1", OrderId: "paper-order-paper-1-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
||||
}
|
||||
if resp.GetOrder().GetStatus() != "canceled" {
|
||||
t.Errorf("expected canceled status, got %q", resp.GetOrder().GetStatus())
|
||||
}
|
||||
if paper.gotCancelOrder != "paper-order-paper-1-1" {
|
||||
t.Errorf("service received wrong order id: %q", paper.gotCancelOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCancelPaperOrderValidation(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
||||
for _, req := range []*altv1.CancelPaperOrderRequest{
|
||||
{OrderId: "o-1"}, // missing account
|
||||
{AccountId: "paper-1"}, // missing order id
|
||||
} {
|
||||
resp, err := handleCancelPaperOrder(deps, req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed validation response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCancelPaperOrderNotPending(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{cancelErr: papertrading.ErrOrderNotPending}}
|
||||
resp, err := handleCancelPaperOrder(deps, &altv1.CancelPaperOrderRequest{AccountId: "paper-1", OrderId: "o-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed invalid_request response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
|
||||
func TestHandleCancelPaperOrderNotFound(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{cancelErr: papertrading.ErrOrderNotFound}}
|
||||
resp, err := handleCancelPaperOrder(deps, &altv1.CancelPaperOrderRequest{AccountId: "paper-1", OrderId: "missing"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed not_found response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorNotFound)
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderSuccess(t *testing.T) {
|
||||
order := sampleOrder()
|
||||
order.Status = papertrading.PaperOrderStatusFilled
|
||||
order.Fill = &backtest.Fill{
|
||||
InstrumentID: "KRX:005930",
|
||||
Side: backtest.OrderSideBuy,
|
||||
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
|
||||
Price: krw("1000"),
|
||||
Timestamp: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
paper := &fakePaperService{order: order, state: sampleState()}
|
||||
deps := Deps{Paper: paper}
|
||||
|
||||
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{
|
||||
AccountId: "paper-1",
|
||||
OrderId: "paper-order-paper-1-1",
|
||||
FillPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "1000"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("unexpected typed error: %+v", resp.GetError())
|
||||
}
|
||||
if resp.GetOrder().GetStatus() != "filled" {
|
||||
t.Errorf("expected filled status, got %q", resp.GetOrder().GetStatus())
|
||||
}
|
||||
if resp.GetOrder().GetFill().GetPrice().GetAmount().GetValue() != "1000" {
|
||||
t.Errorf("fill price mismatch: %q", resp.GetOrder().GetFill().GetPrice().GetAmount().GetValue())
|
||||
}
|
||||
if resp.GetState().GetAccountId() != "paper-1" {
|
||||
t.Errorf("expected state snapshot in response, got %+v", resp.GetState())
|
||||
}
|
||||
if paper.gotFillPrice.Amount.Value != "1000" {
|
||||
t.Errorf("service received wrong fill price: %+v", paper.gotFillPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderRejectedIsTypedSuccess(t *testing.T) {
|
||||
order := sampleOrder()
|
||||
order.Status = papertrading.PaperOrderStatusRejected
|
||||
order.Reason = "insufficient position quantity for sell order"
|
||||
paper := &fakePaperService{order: order, state: sampleState()}
|
||||
deps := Deps{Paper: paper}
|
||||
|
||||
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{
|
||||
AccountId: "paper-1",
|
||||
OrderId: "paper-order-paper-1-1",
|
||||
FillPrice: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "1000"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// A business rejection is a successful response carrying status=rejected.
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("rejection must not surface as a typed error: %+v", resp.GetError())
|
||||
}
|
||||
if resp.GetOrder().GetStatus() != "rejected" || resp.GetOrder().GetReason() == "" {
|
||||
t.Errorf("expected rejected status with reason, got %+v", resp.GetOrder())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderFillPriceRequired(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{fillErr: papertrading.ErrFillPriceRequired}}
|
||||
resp, err := handleFillPaperOrder(deps, &altv1.FillPaperOrderRequest{AccountId: "paper-1", OrderId: "o-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed invalid_request response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
|
||||
func TestHandleFillPaperOrderValidation(t *testing.T) {
|
||||
deps := Deps{Paper: &fakePaperService{order: sampleOrder()}}
|
||||
for _, req := range []*altv1.FillPaperOrderRequest{
|
||||
{OrderId: "o-1"}, // missing account
|
||||
{AccountId: "paper-1"}, // missing order id
|
||||
} {
|
||||
resp, err := handleFillPaperOrder(deps, req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected typed validation response, got error: %v", err)
|
||||
}
|
||||
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaperHandlersCoverAllRequests(t *testing.T) {
|
||||
registered := make(map[string]bool)
|
||||
for _, h := range paperHandlers(Deps{}) {
|
||||
|
|
@ -210,7 +550,13 @@ func TestPaperHandlersCoverAllRequests(t *testing.T) {
|
|||
}
|
||||
registered[h.requestType] = true
|
||||
}
|
||||
for _, req := range []string{"alt.v1.StartPaperTradingRequest", "alt.v1.GetPaperTradingStateRequest"} {
|
||||
for _, req := range []string{
|
||||
"alt.v1.StartPaperTradingRequest",
|
||||
"alt.v1.GetPaperTradingStateRequest",
|
||||
"alt.v1.SubmitPaperOrderRequest",
|
||||
"alt.v1.CancelPaperOrderRequest",
|
||||
"alt.v1.FillPaperOrderRequest",
|
||||
} {
|
||||
if !registered[req] {
|
||||
t.Errorf("missing handler for %q", req)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue