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:
toki 2026-06-06 11:36:50 +09:00
parent 1ad517beec
commit daa29d6807
37 changed files with 5120 additions and 275 deletions

View file

@ -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를 확인한다.

View file

@ -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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -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`
### 테스트 작성

View file

@ -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`를 작성한다.

View file

@ -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!
```

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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?**

View file

@ -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?**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"}

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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