From daa29d6807ceec7ddb9d4ce0f029d38a8642c58d Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 6 Jun 2026 11:36:50 +0900 Subject: [PATCH] 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 --- .../paper-trading-command-workflow.md | 16 +- .../code_review_cloud_G08_0.log | 132 +++ .../02+01_risk_command/complete.log | 46 + .../02+01_risk_command/plan_cloud_G08_0.log} | 26 +- .../code_review_cloud_G09_0.log | 169 ++++ .../code_review_local_G06_1.log | 224 +++++ .../03+02_order_lifecycle/complete.log | 46 + .../plan_cloud_G09_0.log} | 0 .../plan_local_G06_1.log | 105 +++ .../CODE_REVIEW-cloud-G08.md | 92 -- .../CODE_REVIEW-cloud-G09.md | 92 -- apps/cli/internal/operator/client.go | 15 + apps/cli/internal/operator/client_test.go | 84 +- apps/cli/internal/operator/output.go | 57 ++ apps/cli/internal/operator/parser_map.go | 7 + apps/cli/internal/operator/parser_map_test.go | 7 + apps/cli/internal/operator/runner.go | 120 ++- .../internal/operator/runner_paper_test.go | 379 +++++++- apps/cli/internal/operator/scenario.go | 89 ++ .../expected/paper_order_lifecycle.jsonl | 8 + .../expected/paper_trading_state.jsonl | 4 +- .../operator/paper_order_lifecycle.yaml | 76 ++ .../generated/alt/v1/paper_trading.pb.dart | 836 ++++++++++++++++++ .../alt/v1/paper_trading.pbjson.dart | 266 +++++- .../gen/go/alt/v1/paper_trading.pb.go | 746 +++++++++++++++- .../proto/alt/v1/paper_trading.proto | 85 ++ services/api/internal/contracts/parser_map.go | 7 + services/api/internal/socket/backtest_test.go | 26 +- services/api/internal/socket/paper.go | 99 +++ services/api/internal/socket/paper_test.go | 131 +++ services/api/internal/workerclient/client.go | 18 + .../worker/internal/contracts/parser_map.go | 7 + .../worker/internal/papertrading/service.go | 401 ++++++++- .../internal/papertrading/service_test.go | 343 +++++++ services/worker/internal/socket/paper.go | 120 ++- .../worker/internal/socket/paper_mapping.go | 168 +++- services/worker/internal/socket/paper_test.go | 348 +++++++- 37 files changed, 5120 insertions(+), 275 deletions(-) create mode 100644 agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/code_review_cloud_G08_0.log create mode 100644 agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/complete.log rename agent-task/{m-paper-trading-command-workflow/02+01_risk_command/PLAN-cloud-G08.md => archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/plan_cloud_G08_0.log} (90%) create mode 100644 agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_cloud_G09_0.log create mode 100644 agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_local_G06_1.log create mode 100644 agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/complete.log rename agent-task/{m-paper-trading-command-workflow/03+02_order_lifecycle/PLAN-cloud-G09.md => archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/plan_cloud_G09_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/plan_local_G06_1.log delete mode 100644 agent-task/m-paper-trading-command-workflow/02+01_risk_command/CODE_REVIEW-cloud-G08.md delete mode 100644 agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/CODE_REVIEW-cloud-G09.md create mode 100644 apps/cli/testdata/operator/expected/paper_order_lifecycle.jsonl create mode 100644 apps/cli/testdata/operator/paper_order_lifecycle.yaml diff --git a/agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md b/agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md index 5774563..4bb6367 100644 --- a/agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md +++ b/agent-roadmap/phase/trading-expansion/milestones/paper-trading-command-workflow.md @@ -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를 확인한다. diff --git a/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/code_review_cloud_G08_0.log b/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/code_review_cloud_G08_0.log new file mode 100644 index 0000000..9e965a7 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/code_review_cloud_G08_0.log @@ -0,0 +1,132 @@ + + +# 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/complete.log b/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/complete.log new file mode 100644 index 0000000..f2f7077 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-paper-trading-command-workflow/02+01_risk_command/PLAN-cloud-G08.md b/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/plan_cloud_G08_0.log similarity index 90% rename from agent-task/m-paper-trading-command-workflow/02+01_risk_command/PLAN-cloud-G08.md rename to agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/plan_cloud_G08_0.log index 88aaaf0..97b6d40 100644 --- a/agent-task/m-paper-trading-command-workflow/02+01_risk_command/PLAN-cloud-G08.md +++ b/agent-task/archive/2026/06/m-paper-trading-command-workflow/02+01_risk_command/plan_cloud_G08_0.log @@ -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` ### 테스트 작성 diff --git a/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_cloud_G09_0.log b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_cloud_G09_0.log new file mode 100644 index 0000000..0732b8e --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_cloud_G09_0.log @@ -0,0 +1,169 @@ + + +# 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--` 형식이며 주입 가능한 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`를 작성한다. diff --git a/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_local_G06_1.log b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_local_G06_1.log new file mode 100644 index 0000000..a551053 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/code_review_local_G06_1.log @@ -0,0 +1,224 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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--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! +``` diff --git a/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/complete.log b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/complete.log new file mode 100644 index 0000000..108b717 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/PLAN-cloud-G09.md b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/plan_cloud_G09_0.log similarity index 100% rename from agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/PLAN-cloud-G09.md rename to agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/plan_cloud_G09_0.log diff --git a/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/plan_local_G06_1.log b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/plan_local_G06_1.log new file mode 100644 index 0000000..a549162 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-command-workflow/03+02_order_lifecycle/plan_local_G06_1.log @@ -0,0 +1,105 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-paper-trading-command-workflow/02+01_risk_command/CODE_REVIEW-cloud-G08.md b/agent-task/m-paper-trading-command-workflow/02+01_risk_command/CODE_REVIEW-cloud-G08.md deleted file mode 100644 index f132fcf..0000000 --- a/agent-task/m-paper-trading-command-workflow/02+01_risk_command/CODE_REVIEW-cloud-G08.md +++ /dev/null @@ -1,92 +0,0 @@ - - -# 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?** diff --git a/agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/CODE_REVIEW-cloud-G09.md b/agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/CODE_REVIEW-cloud-G09.md deleted file mode 100644 index 8ca3ee3..0000000 --- a/agent-task/m-paper-trading-command-workflow/03+02_order_lifecycle/CODE_REVIEW-cloud-G09.md +++ /dev/null @@ -1,92 +0,0 @@ - - -# 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?** diff --git a/apps/cli/internal/operator/client.go b/apps/cli/internal/operator/client.go index 3e31910..313e6c5 100644 --- a/apps/cli/internal/operator/client.go +++ b/apps/cli/internal/operator/client.go @@ -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. diff --git a/apps/cli/internal/operator/client_test.go b/apps/cli/internal/operator/client_test.go index 5353ef4..cf9ba5b 100644 --- a/apps/cli/internal/operator/client_test.go +++ b/apps/cli/internal/operator/client_test.go @@ -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 { diff --git a/apps/cli/internal/operator/output.go b/apps/cli/internal/operator/output.go index 46117e1..2a8e62a 100644 --- a/apps/cli/internal/operator/output.go +++ b/apps/cli/internal/operator/output.go @@ -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) } diff --git a/apps/cli/internal/operator/parser_map.go b/apps/cli/internal/operator/parser_map.go index e76c332..e0f0014 100644 --- a/apps/cli/internal/operator/parser_map.go +++ b/apps/cli/internal/operator/parser_map.go @@ -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{} }, } } diff --git a/apps/cli/internal/operator/parser_map_test.go b/apps/cli/internal/operator/parser_map_test.go index 514ec00..a937635 100644 --- a/apps/cli/internal/operator/parser_map_test.go +++ b/apps/cli/internal/operator/parser_map_test.go @@ -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) diff --git a/apps/cli/internal/operator/runner.go b/apps/cli/internal/operator/runner.go index 0bfc858..e786ec1 100644 --- a/apps/cli/internal/operator/runner.go +++ b/apps/cli/internal/operator/runner.go @@ -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..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 } diff --git a/apps/cli/internal/operator/runner_paper_test.go b/apps/cli/internal/operator/runner_paper_test.go index 3e85bad..67638b1 100644 --- a/apps/cli/internal/operator/runner_paper_test.go +++ b/apps/cli/internal/operator/runner_paper_test.go @@ -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) + } +} diff --git a/apps/cli/internal/operator/scenario.go b/apps/cli/internal/operator/scenario.go index 89871a0..e4f53fc 100644 --- a/apps/cli/internal/operator/scenario.go +++ b/apps/cli/internal/operator/scenario.go @@ -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..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) diff --git a/apps/cli/testdata/operator/expected/paper_order_lifecycle.jsonl b/apps/cli/testdata/operator/expected/paper_order_lifecycle.jsonl new file mode 100644 index 0000000..de4769c --- /dev/null +++ b/apps/cli/testdata/operator/expected/paper_order_lifecycle.jsonl @@ -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"} diff --git a/apps/cli/testdata/operator/expected/paper_trading_state.jsonl b/apps/cli/testdata/operator/expected/paper_trading_state.jsonl index dd36922..44bdd74 100644 --- a/apps/cli/testdata/operator/expected/paper_trading_state.jsonl +++ b/apps/cli/testdata/operator/expected/paper_trading_state.jsonl @@ -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"} diff --git a/apps/cli/testdata/operator/paper_order_lifecycle.yaml b/apps/cli/testdata/operator/paper_order_lifecycle.yaml new file mode 100644 index 0000000..e1ce1a7 --- /dev/null +++ b/apps/cli/testdata/operator/paper_order_lifecycle.yaml @@ -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 diff --git a/apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart b/apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart index 3c252d1..a254a48 100644 --- a/apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart +++ b/apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart @@ -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? 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(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 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(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(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(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(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(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(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(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(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(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(1, _omitFieldNames ? '' : 'order', + subBuilder: PaperOrder.create) + ..aOM(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(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 = diff --git a/apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart b/apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart index 976a07d..48bc5e1 100644 --- a/apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart +++ b/apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart @@ -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'); diff --git a/packages/contracts/gen/go/alt/v1/paper_trading.pb.go b/packages/contracts/gen/go/alt/v1/paper_trading.pb.go index 2d1f303..1049fd4 100644 --- a/packages/contracts/gen/go/alt/v1/paper_trading.pb.go +++ b/packages/contracts/gen/go/alt/v1/paper_trading.pb.go @@ -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, }, diff --git a/packages/contracts/proto/alt/v1/paper_trading.proto b/packages/contracts/proto/alt/v1/paper_trading.proto index 08523d7..0132dfd 100644 --- a/packages/contracts/proto/alt/v1/paper_trading.proto +++ b/packages/contracts/proto/alt/v1/paper_trading.proto @@ -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; } diff --git a/services/api/internal/contracts/parser_map.go b/services/api/internal/contracts/parser_map.go index 56fa0be..d827a7c 100644 --- a/services/api/internal/contracts/parser_map.go +++ b/services/api/internal/contracts/parser_map.go @@ -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{} }, } } diff --git a/services/api/internal/socket/backtest_test.go b/services/api/internal/socket/backtest_test.go index 4fef4a2..a9c6385 100644 --- a/services/api/internal/socket/backtest_test.go +++ b/services/api/internal/socket/backtest_test.go @@ -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 diff --git a/services/api/internal/socket/paper.go b/services/api/internal/socket/paper.go index 48ad94c..8bb3665 100644 --- a/services/api/internal/socket/paper.go +++ b/services/api/internal/socket/paper.go @@ -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 +} diff --git a/services/api/internal/socket/paper_test.go b/services/api/internal/socket/paper_test.go index 5eb9f59..c70a853 100644 --- a/services/api/internal/socket/paper_test.go +++ b/services/api/internal/socket/paper_test.go @@ -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) diff --git a/services/api/internal/workerclient/client.go b/services/api/internal/workerclient/client.go index 85b5cc2..ef599c8 100644 --- a/services/api/internal/workerclient/client.go +++ b/services/api/internal/workerclient/client.go @@ -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) } diff --git a/services/worker/internal/contracts/parser_map.go b/services/worker/internal/contracts/parser_map.go index c0e8456..20ac1b7 100644 --- a/services/worker/internal/contracts/parser_map.go +++ b/services/worker/internal/contracts/parser_map.go @@ -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{} }, } } diff --git a/services/worker/internal/papertrading/service.go b/services/worker/internal/papertrading/service.go index 7f2f553..0c53ba8 100644 --- a/services/worker/internal/papertrading/service.go +++ b/services/worker/internal/papertrading/service.go @@ -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 +} diff --git a/services/worker/internal/papertrading/service_test.go b/services/worker/internal/papertrading/service_test.go index 572e02c..66cf370 100644 --- a/services/worker/internal/papertrading/service_test.go +++ b/services/worker/internal/papertrading/service_test.go @@ -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) + } +} diff --git a/services/worker/internal/socket/paper.go b/services/worker/internal/socket/paper.go index b7327c4..6948347 100644 --- a/services/worker/internal/socket/paper.go +++ b/services/worker/internal/socket/paper.go @@ -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: diff --git a/services/worker/internal/socket/paper_mapping.go b/services/worker/internal/socket/paper_mapping.go index b61f449..afdba0b 100644 --- a/services/worker/internal/socket/paper_mapping.go +++ b/services/worker/internal/socket/paper_mapping.go @@ -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 } diff --git a/services/worker/internal/socket/paper_test.go b/services/worker/internal/socket/paper_test.go index ab30620..cac0a16 100644 --- a/services/worker/internal/socket/paper_test.go +++ b/services/worker/internal/socket/paper_test.go @@ -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) }