feat(paper-trading): paper 상태 모니터링을 연결한다

Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
This commit is contained in:
toki 2026-06-05 15:14:41 +09:00
parent 51f8a6b2ae
commit aaf1af6387
55 changed files with 5217 additions and 281 deletions

View file

@ -0,0 +1,196 @@
<!-- task=m-paper-trading-readiness/02+01_worker_paper_runtime plan=0 tag=PAPER_WORKER -->
# Code Review Reference - PAPER_WORKER
> **[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`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-06-05
task=m-paper-trading-readiness/02+01_worker_paper_runtime, plan=0, tag=PAPER_WORKER
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `strategy-run`: 전략을 paper execution loop에서 실행할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [PAPER_WORKER-1] Worker Paper Runtime | [x] |
## 구현 체크리스트
- [x] Predecessor `01_domain_paper_model` PASS `complete.log`를 확인한다. (`agent-task/archive/2026/06/m-paper-trading-readiness/01_domain_paper_model/complete.log`)
- [x] `services/worker/internal/papertrading/` package를 추가해 daily bar paper execution loop를 구현한다.
- [x] strategy decisions, pending orders, next-bar fills, portfolio/equity snapshots, risk guard rejection을 테스트한다. (5 test)
- [x] `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`를 실행한다.
- [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_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/`를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent 유지/제거 여부를 확인한다.
- [x] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 흐름을 따른다.
## 계획 대비 변경 사항
- `RunRequest` 구조체에 `Account`와 `Run` 필드를 포함해 단일 요청 객체로 사용한다 (PLAN과 일치).
- `Snapshot`에 `Account`, `Fills`, `Rejected`, `EquityCurve` 필드를 포함해 PLAN의 `Snapshot` 구조와 일치한다.
- `RejectedOrder`는 PLAN에 명시된 것보다 상세한 정보(`Instrument`, `CashBefore`, `BarTime`)를 포함한다.
- `backtest.PortfolioState.clone()`은 unexported 메서드이므로 engine.go에서 직접 clone 코드를 구현했다.
- `History` 필드는 strategy input에 전달되지 않는다 (nil). paper trading에서 history 기반 전략은 별도 작업으로 처리.
- `backtest/engine.go`는 수정하지 않고 변경 없이 유지했다.
## 주요 설계 결정
- next-bar fill 패턴: strategy가 bar N에서 decision하면, pending orders로 다음 bar(N+1)에서 `backtest.FillOrderOnDailyBar`로 실행한다. Market order는 bar open price, limit order는 low/high crossing.
- risk 체크: `backtest.CheckRisk`를 `FillOrderOnDailyBar` 전에 호출한다. 금지된 주문은 `Snapshot.Rejected`에 기록되고 포트폴리오도 pending orders에서도 discarded된다.
- clone: `PortfolioState.clone()`이 unexported이므로 engine.go에서 positions map을 직접 복사해 account state를 불변으로 유지한다.
- equity snapshot: 각 bar 종료 시 mark price를 적용한 후 equity를 계산해 `EquityCurve`에 기록한다. Strategy buy/ sell이 같은 bar에 fired되어도 market order는 다음 bar open에서 fills되므로 equity는 순차적으로 계산된다.
- empty bars: bars가 없으면 비어있는 snapshot(초기 account 상태 유지)를 반환해 nil dereference를 방지한다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Predecessor complete.log is cited before implementation.
- Backtest engine behavior remains unchanged.
- Paper engine uses next-bar market open and daily limit crossing from domain helper.
- Risk-denied orders do not mutate portfolio.
- `Snapshot.Rejected` is populated for risk-denied orders.
- Equity snapshots are recorded after marking position price.
## 검증 결과
_구현 에이gen트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### PAPER_WORKER-1 중간 검증
```text
$ go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.003s
$ git diff --check
(no output - no whitespace errors)
```
### 최종 검증
```text
$ go test ./packages/domain/... -count=1
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.007s
ok git.toki-labs.com/toki/alt/packages/domain/market 0.009s
$ go test ./services/worker/... -count=1
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.010s
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.007s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.007s
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.008s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.028s
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.070s
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.222s
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ git diff --check
(no output - no whitespace errors)
```
---
> **[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.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Warn
- plan deviation: Warn
- verification trust: Pass
- 발견된 문제:
- Required: `services/worker/internal/papertrading/engine.go:111`에서 모든 pending order를 현재 loop의 다음 `bar`에 무조건 대입합니다. `StorageBarSource`는 한 market의 여러 instrument bar를 timestamp/instrument 순으로 반환하므로, instrument A에서 생성된 주문이 같은 날짜의 instrument B bar 가격으로 체결될 수 있고 "next daily bar" 계약도 깨집니다. Fix: pending order를 `InstrumentID`별로 보관하고 `bar.InstrumentID == order.InstrumentID`인 다음 bar에서만 `FillOrderOnDailyBar`를 호출하며, multi-instrument 회귀 테스트를 추가하세요.
- Required: `services/worker/internal/papertrading/engine.go:134`에서 `ApplyFill` 실패를 rejection이나 error로 남기지 않고 `nextPending`에 다시 넣습니다. 현금 부족 buy처럼 `CheckRisk`를 통과했지만 포트폴리오 적용이 실패한 주문은 `Snapshot.Rejected`에도 보이지 않고 계속 재시도됩니다. Fix: apply 실패를 `RejectedOrder`로 기록하고 pending에서 제거하거나, abort 정책이면 error를 반환하세요. 부족 현금 market buy 테스트를 추가하세요.
- Required: `services/worker/internal/papertrading/engine.go:182`에서 입력 `req.Account.ID`를 첫 bar에서 `req.Run.ID`로 덮어씁니다. `RunRequest.Account`의 paper account state를 반환해야 하는 snapshot이 run identity로 오염됩니다. Fix: 입력 account ID를 보존하고, 빈 ID에 대한 fallback이 필요하면 명시적 정책과 테스트를 추가하세요.
- Nit: `gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go`가 `services/worker/internal/papertrading/engine_test.go`를 출력합니다. Fix: 후속 수정 후 `gofmt`를 적용하세요.
- 다음 단계: FAIL 후속 plan/review를 작성한다.
검증 재실행:
```text
$ go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s
$ go test ./packages/domain/... -count=1
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s
ok git.toki-labs.com/toki/alt/packages/domain/market 0.002s
$ go test ./services/worker/... -count=1
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.004s
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.009s
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.061s
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.059s
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ git diff --check
(no output)
$ gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
services/worker/internal/papertrading/engine_test.go
```

View file

@ -0,0 +1,212 @@
<!-- task=m-paper-trading-readiness/02+01_worker_paper_runtime plan=1 tag=REVIEW_PAPER_WORKER -->
# Code Review Reference - REVIEW_PAPER_WORKER
> **[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.
> 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-05
task=m-paper-trading-readiness/02+01_worker_paper_runtime, plan=1, tag=REVIEW_PAPER_WORKER
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `strategy-run`: 전략을 paper execution loop에서 실행할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G08.md` -> `code_review_local_G08_N.log`, `PLAN-local-G08.md` -> `plan_local_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-paper-trading-readiness/02+01_worker_paper_runtime/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-paper-trading-readiness`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|--|
| [REVIEW_PAPER_WORKER-1] Instrument-scoped Pending Orders | [x] |
| [REVIEW_PAPER_WORKER-2] Fill Apply Failure Handling | [x] |
| [REVIEW_PAPER_WORKER-3] Account Identity Preservation | [x] |
## 구현 체크리스트
- [x] Pending orders를 instrument별 다음 daily bar에서만 체결하도록 수정하고 multi-instrument 회귀 테스트를 추가한다.
- [x] `ApplyFill` 실패를 `RejectedOrder` 또는 명시적 abort 정책으로 처리하고 부족 현금 회귀 테스트를 추가한다.
- [x] `RunRequest.Account.ID`를 보존하도록 수정하고 account identity 회귀 테스트를 추가한다.
- [x] `gofmt`를 적용하고 `go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1`, `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`, `gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go`를 실행한다.
- [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_local_G08_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G08_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/`를 `agent-task/archive/YYYY/MM/m-paper-trading-readiness/02+01_worker_paper_runtime/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-paper-trading-readiness/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
- PLAN의 해결 방법을 정확하게 따랐다: pendingOrders를 `[]backtest.OrderIntent`에서 `map[market.InstrumentID][]backtest.OrderIntent`로 변경, ApplyFill 실패를 `RejectedOrder`로 기록 후 pending 제거, `req.Account.ID` 보존.
- `services/worker/internal/backtest/engine.go` 수정 없음.
## 주요 설계 결정
- instrument-scoped pending: `map[market.InstrumentID][]backtest.OrderIntent`을 사용해 같은 InstrumentID의 bar가 올 때까지 주문을 pending으로 유지한다. 다른 instrument의 bar가 와도 해당 instrument의 pending에는 영향을 주지 않는다.
- ApplyFill 실패 → RejectedOrder: `backtest.CheckRisk`를 통과했지만 `Portfolio.ApplyFill`이 실패한 경우(예: 부족 현금) `RejectedOrder`로 기록하고 pending orders에서 완전히 제거한다. 이 전략을 선택한 이유: 무한 retry 방지 + snapshot에서 결함을 명확히 가시화.
- Account ID 보존: `req.Account.ID`를 그대로 `Snapshot.Account`에 전달한다. 첫 bar에서 `Run.ID`로 덮어쓰던 코드를 제거했다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Pending orders are keyed or otherwise scoped so an order only fills on a later bar for the same `InstrumentID`.
- Multi-instrument test proves same-date other-instrument bars do not consume pending orders.
- `ApplyFill` failures are visible through `Snapshot.Rejected` or an intentional returned error, and failed orders are not retried forever.
- `RunRequest.Account.ID` is preserved in `Snapshot.Account.ID`.
- `gofmt -l` reports no files.
- `engine.go` does not override `req.Account.ID` in the loop.
- Instrument-scoped pending ensures `pendingOrders[bar.InstrumentID]` is keyed, not a flat slice shared across instruments.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_PAPER_WORKER 중간 검증
```text
$ go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s
$ gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
(no output)
```
### 최종 검증
```text
$ go test ./services/worker/... -count=1
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.005s
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.006s
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.019s
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.061s
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.087s
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ go test ./packages/domain/... -count=1
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s
ok git.toki-labs.com/toki/alt/packages/domain/market 0.002s
$ git diff --check
(no output)
$ gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
(no output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Fail
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: `services/worker/internal/papertrading/engine.go:183`에서 strategy가 반환한 주문을 `order.InstrumentID`가 아니라 현재 `bar.InstrumentID` bucket에 저장합니다. `Strategy.Decide`의 반환 타입은 `[]OrderIntent`이고 각 order가 자체 `InstrumentID`를 가지므로, 현재 bar와 다른 instrument 주문을 낼 수 있습니다. 그 경우 다음 현재-bar instrument에서 `FillOrderOnDailyBar(order, bar)`가 호출되어 주문 instrument ID는 유지한 채 다른 instrument 가격으로 체결됩니다. Fix: `for _, order := range strategyOrders`로 순회해 `pendingOrders[order.InstrumentID]`에 저장하고, 현재 bar와 다른 instrument 주문이 같은 instrument의 다음 bar에서만 체결되는 회귀 테스트를 추가하세요. 빈 `InstrumentID` 처리 정책도 명시하세요.
- Required: `go.mod:1`에 루트 module file이 untracked로 생겼습니다. ALT는 `go.work` 기반 Go 1.22 multi-module workspace이고 이번 계획 범위도 `services/worker/internal/papertrading/**`로 제한되어 있어 루트 `go.mod` 생성은 범위 이탈입니다. Fix: 이번 작업 산출물에서 루트 `go.mod`를 제거하세요. 만약 사용자 소유 unrelated 파일이면 증거를 review stub에 남기고 task 산출물로 포함하지 마세요.
- 다음 단계: FAIL 후속 plan/review를 작성한다.
검증 재실행:
```text
$ go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s
$ go test ./packages/domain/... -count=1
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s
ok git.toki-labs.com/toki/alt/packages/domain/market 0.002s
$ gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
(no output)
$ git diff --check
(no output)
$ go test ./services/worker/... -count=1
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.005s
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.006s
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.010s
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.063s
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.060s
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
```

View file

@ -0,0 +1,228 @@
<!-- task=m-paper-trading-readiness/02+01_worker_paper_runtime plan=2 tag=REVIEW_REVIEW_PAPER_WORKER -->
# Code Review Reference - REVIEW_REVIEW_PAPER_WORKER
> **[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.
> Do not prompt the user directly. 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.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
## 개요
date=2026-06-05
task=m-paper-trading-readiness/02+01_worker_paper_runtime, plan=2, tag=REVIEW_REVIEW_PAPER_WORKER
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `strategy-run`: 전략을 paper execution loop에서 실행할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_PAPER_WORKER-1] Order Instrument Pending Key | [x] |
| [REVIEW_REVIEW_PAPER_WORKER-2] Root go.mod Scope Cleanup | [x] |
## 구현 체크리스트
- [x] Strategy가 반환한 pending order를 `bar.InstrumentID`가 아니라 각 `order.InstrumentID` bucket에 저장하고 cross-instrument order 회귀 테스트를 추가한다.
- [x] 루트 `go.mod` 범위 이탈을 정리하고, 사용자 소유 unrelated 파일로 판단되면 증거와 함께 `사용자 리뷰 요청`에 남긴다.
- [x] `go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1`, `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`, `gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go`, `git status --short --untracked-files=all`를 실행한다.
- [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_local_G09_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G09_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-readiness/02+01_worker_paper_runtime/`를 `agent-task/archive/YYYY/MM/m-paper-trading-readiness/02+01_worker_paper_runtime/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-paper-trading-readiness/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
- PLAN의 해결 방법([REVIEW_REVIEW_PAPER_WORKER-1] 해결 방법)을 정확히 따랐다: strategyOrders 순회 시 `pendingOrders[order.InstrumentID]`에 저장, 빈 InstrumentID는 reject 처리.
- 추가 설계: 빈 `OrderIntent.InstrumentID`는 현재 `bar.InstrumentID`로 보정하지 않고 즉시 rejected 처리 (strategy bug 조기에 드러내기 위함). 관련 회귀 테스트 `TestEngineEmptyInstrumentIDRejected` 추가.
- PLAN의 해결 방법([REVIEW_REVIEW_PAPER_WORKER-2])을 따라 루트 `go.mod` 범위 이탈을 정리했고, `git status --short --untracked-files=all`에서 더 이상 루트 `go.mod`가 표시되지 않음을 확인했다.
## 주요 설계 결정
- order.InstrumentID 기반 pending: strategy가 반환한 주문들을 각 주문의 `InstrumentID`를 키로 `pendingOrders` map에 저장한다. 같은 `InstrumentID`의 bar가 올 때까지 주문을 pending으로 유지하며, 다른 instrument bar에서는 이 pending에 접근하지 않는다.
- 빈 InstrumentID reject: `OrderIntent.InstrumentID`가 비어 있으면 strategy bug로 판단하며, 현재 bar instrument로 보정하지 않고 즉시 rejected로 처리한다. 빈 키 보정을 방지하고 전략 버그가 감춰지지 않도록 하는 것이 목적.
- 루트 go.mod 제거: `go.work`(go 1.22)와 호환되지 않는 `go 1.26.2` 선언으로 workspace 충돌 유발. 파일 기원을 특정할 수 없으나 G09의 작업 범위( papertrading package)에 포함되지 않아 제거 결정.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Strategy orders are stored by `order.InstrumentID`, not the current `bar.InstrumentID`.
- Cross-instrument order test proves B order emitted while processing A fills on B's next bar, not A's next bar.
- Empty `OrderIntent.InstrumentID` policy is explicit and tested if implemented.
- Root `go.mod` is gone from task changes, or `사용자 리뷰 요청` documents why it is user-owned unrelated.
- Verification output includes `git status --short --untracked-files=all`.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_REVIEW_PAPER_WORKER 중간 검증
```text
$ go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s
$ gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
(no output)
```
### 최종 검증
```text
$ go test ./services/worker/... -count=1
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.008s
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.013s
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.061s
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.091s
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ go test ./packages/domain/... -count=1
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s
ok git.toki-labs.com/toki/alt/packages/domain/market 0.001s
$ git diff --check
(no output)
$ gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
(no output)
$ git status --short --untracked-files=all
D agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G07.md
AD agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G08.md
d agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G07.md
AD agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G08.md
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G09.md
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G09.md
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G07_0.log
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G08_1.log
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G07_0.log
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G08_1.log
?? services/worker/internal/papertrading/engine.go
?? services/worker/internal/papertrading/engine_test.go
```
---
> **[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.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 완료 처리한다.
검증 재실행:
```text
$ go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.002s
$ go test ./packages/domain/... -count=1
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s
ok git.toki-labs.com/toki/alt/packages/domain/market 0.001s
$ gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
(no output)
$ git diff --check
(no output)
$ go test ./services/worker/... -count=1
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.005s
? git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-data-check [no test files]
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker-migrate 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/config 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck 0.005s
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis 0.009s
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys 0.002s
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.059s
? git.toki-labs.com/toki/alt/services/worker/internal/storage [no test files]
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.061s
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ git status --short --untracked-files=all
D agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G07.md
AD agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G08.md
D agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G07.md
AD agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G08.md
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G09.md
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G09.md
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G07_0.log
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G08_1.log
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G07_0.log
?? agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G08_1.log
?? services/worker/internal/papertrading/engine.go
?? services/worker/internal/papertrading/engine_test.go
```

View file

@ -0,0 +1,48 @@
# Complete - m-paper-trading-readiness/02+01_worker_paper_runtime
## 완료 일시
2026-06-05
## 요약
Worker paper runtime completed after 3 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | FAIL | Pending orders were not instrument-scoped; ApplyFill failures were not visible; account ID was overwritten. |
| `plan_local_G08_1.log` | `code_review_local_G08_1.log` | FAIL | Remaining cross-instrument order bucket issue and root `go.mod` scope drift. |
| `plan_local_G09_2.log` | `code_review_local_G09_2.log` | PASS | Required issues resolved with order InstrumentID pending keys, empty InstrumentID rejection, go.mod cleanup, and focused tests. |
## 구현/정리 내용
- Added `services/worker/internal/papertrading` daily next-bar paper execution runtime.
- Implemented instrument-scoped pending orders, next-bar market/limit fills, equity snapshots, risk and ApplyFill rejection handling, and account identity preservation.
- Added focused paper runtime tests for next-bar fills, limit crossing/pending behavior, rejection behavior, equity snapshots, cross-instrument order routing, and empty InstrumentID rejection.
- Removed the accidental root `go.mod` scope drift from the active task state.
## 최종 검증
- `go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1` - PASS; papertrading and backtest packages passed.
- `go test ./services/worker/... -count=1` - PASS; worker packages passed, with expected `[no test files]` packages.
- `go test ./packages/domain/... -count=1` - PASS; domain backtest and market packages passed.
- `git diff --check` - PASS; no output.
- `gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go` - PASS; no output.
- `git status --short --untracked-files=all` - PASS for G09 cleanup; no root `go.mod` remains.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Completed task ids:
- `strategy-run`: PASS; evidence=`plan_local_G07_0.log`, `code_review_local_G07_0.log`, `plan_local_G08_1.log`, `code_review_local_G08_1.log`, `plan_local_G09_2.log`, `code_review_local_G09_2.log`; verification=`go test ./services/worker/... -count=1`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -87,11 +87,11 @@ Domain paper model이 준비되면 worker는 broker 없이 strategy를 daily pap
## 구현 체크리스트
- [ ] Predecessor `01_domain_paper_model` PASS `complete.log`를 확인한다.
- [ ] `services/worker/internal/papertrading/` package를 추가해 daily bar paper execution loop를 구현한다.
- [ ] strategy decisions, pending orders, next-bar fills, portfolio/equity snapshots, risk guard rejection을 테스트한다.
- [ ] `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] Predecessor `01_domain_paper_model` PASS `complete.log`를 확인한다.
- [x] `services/worker/internal/papertrading/` package를 추가해 daily bar paper execution loop를 구현한다.
- [x] strategy decisions, pending orders, next-bar fills, portfolio/equity snapshots, risk guard rejection을 테스트한다.
- [x] `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
@ -154,9 +154,9 @@ Loop:
### 수정 파일 및 체크리스트
- [ ] Add `services/worker/internal/papertrading/engine.go`.
- [ ] Add `services/worker/internal/papertrading/engine_test.go`.
- [ ] Keep `services/worker/internal/backtest/engine.go` behavior unchanged.
- [x] Add `services/worker/internal/papertrading/engine.go`.
- [x] Add `services/worker/internal/papertrading/engine_test.go`.
- [x] Keep `services/worker/internal/backtest/engine.go` behavior unchanged.
### 테스트 작성

View file

@ -0,0 +1,109 @@
<!-- task=m-paper-trading-readiness/02+01_worker_paper_runtime plan=1 tag=REVIEW_PAPER_WORKER -->
# Plan - REVIEW_PAPER_WORKER
## 이 파일을 읽는 구현 에이전트에게
이 계획은 `code_review_local_G07_0.log`의 FAIL Required 이슈만 좁게 해결한다. 구현 완료의 마지막 단계는 반드시 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증 명령을 실행하고 실제 stdout/stderr를 붙여 넣은 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 범위 충돌 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션을 증거와 함께 채우고 중단한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `strategy-run`: 전략을 paper execution loop에서 실행할 수 있다.
- Completion mode: check-on-pass
## 배경
첫 리뷰는 테스트 재실행 자체는 통과했지만, paper execution loop의 pending order 처리에서 multi-instrument daily bar 계약과 fill 실패 기록이 깨지는 Required 이슈를 발견했다. 이 후속 작업은 새 worker package 범위 안에서 paper runtime correctness와 테스트를 보강한다.
## 범위 결정 근거
- Include: `services/worker/internal/papertrading/engine.go`, `services/worker/internal/papertrading/engine_test.go`.
- Include: 필요한 경우 gofmt로 위 파일 formatting을 정리한다.
- Exclude: protobuf/API/CLI monitoring, database persistence, live broker adapter, domain model shape 변경.
## 빌드 등급
- build: `local-G08`, review: `local-G08`.
- 근거: Required 이슈가 명확하고 deterministic unit test로 재현 가능하지만, 기존 local-G07 구현에서 correctness/test coverage 결함이 발생했으므로 local grade를 한 단계 올린다.
## 구현 체크리스트
- [x] Pending orders를 instrument별 다음 daily bar에서만 체결하도록 수정하고 multi-instrument 회귀 테스트를 추가한다.
- [x] `ApplyFill` 실패를 `RejectedOrder` 또는 명시적 abort 정책으로 처리하고 부족 현금 회귀 테스트를 추가한다.
- [x] `RunRequest.Account.ID`를 보존하도록 수정하고 account identity 회귀 테스트를 추가한다.
- [x] `gofmt`를 적용하고 `go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1`, `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`, `gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_PAPER_WORKER-1] Instrument-scoped Pending Orders
### 문제
`services/worker/internal/papertrading/engine.go:111`는 모든 pending order를 현재 loop의 다음 `bar`에 대입한다. `StorageBarSource`는 한 market의 여러 instrument bar를 timestamp/instrument 순으로 반환하므로, instrument A 주문이 같은 날짜의 instrument B bar 가격으로 체결될 수 있다.
### 해결 방법
- Pending order를 `market.InstrumentID` 기준으로 분리한다.
- `FillOrderOnDailyBar`는 `bar.InstrumentID == order.InstrumentID`인 다음 bar에서만 호출한다.
- 같은 timestamp의 다른 instrument bar는 해당 주문의 next bar로 취급하지 않는다.
- Strategy decision은 현재 bar instrument에 대해 생성하되, 다음 동일 instrument daily bar까지 pending으로 유지한다.
### 테스트 작성
- `services/worker/internal/papertrading/engine_test.go`에 multi-instrument bar fixture를 추가한다.
- instrument A day1 주문이 instrument B day1/day2 가격으로 체결되지 않고 instrument A day2 open으로 체결되는지 검증한다.
## [REVIEW_PAPER_WORKER-2] Fill Apply Failure Handling
### 문제
`services/worker/internal/papertrading/engine.go:134`는 `ApplyFill` 실패를 `RejectedOrder`로 기록하지 않고 같은 order를 `nextPending`에 다시 넣는다. 부족 현금 buy처럼 `CheckRisk`를 통과했지만 portfolio apply가 실패한 주문은 snapshot에서 보이지 않고 계속 재시도된다.
### 해결 방법
- `ApplyFill` 실패 정책을 하나로 고정한다.
- 권장: apply 실패를 `RejectedOrder{Reason: err.Error(), BarTime: bar.Timestamp, Instrument: order.InstrumentID}`로 기록하고 pending에서 제거한다.
- abort 정책을 선택한다면 `Run`이 error를 반환하도록 하고 테스트/설계 결정에 이유를 남긴다.
### 테스트 작성
- 부족 현금 market buy가 포트폴리오를 변경하지 않고 `Snapshot.Rejected`에 남는지 검증한다.
- 같은 주문이 이후 bar에서 재시도되어 fill로 바뀌지 않는지 검증한다.
## [REVIEW_PAPER_WORKER-3] Account Identity Preservation
### 문제
`services/worker/internal/papertrading/engine.go:182`는 입력 `req.Account.ID`를 첫 bar에서 `req.Run.ID`로 덮어쓴다. Snapshot은 요청 account state를 반환해야 하므로 paper account identity를 run identity로 바꾸면 후속 API/CLI monitoring 단계에서 상태 추적이 흔들린다.
### 해결 방법
- 기본 동작은 `req.Account.ID`를 그대로 보존한다.
- 빈 ID fallback이 필요하면 명시적으로만 적용하고, `Run.ID`를 계정 ID로 쓰는 정책을 코드/테스트로 정당화한다.
### 테스트 작성
- `RunRequest.Account.ID`가 `Run.ID`와 다를 때 `Snapshot.Account.ID`가 입력 account ID를 유지하는지 검증한다.
## 중간 검증
```bash
go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
```
Expected: `go test` exit 0, `gofmt -l` no output.
## 최종 검증
```bash
go test ./services/worker/... -count=1
go test ./packages/domain/... -count=1
git diff --check
gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
```
Expected: all commands exit 0; `git diff --check` and `gofmt -l` produce no output.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,100 @@
<!-- task=m-paper-trading-readiness/02+01_worker_paper_runtime plan=2 tag=REVIEW_REVIEW_PAPER_WORKER -->
# Plan - REVIEW_REVIEW_PAPER_WORKER
## 이 파일을 읽는 구현 에이전트에게
이 계획은 `code_review_local_G08_1.log`의 FAIL Required 이슈만 좁게 해결한다. 구현 중 사용자에게 직접 질문하지 않는다. 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 범위 충돌 없이는 안전하게 진행할 수 없으면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션을 증거와 함께 채우고 중단한다. 구현 완료의 마지막 단계는 active review stub의 구현 에이전트 소유 섹션에 실제 구현 내용과 검증 stdout/stderr를 채우는 것이다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `strategy-run`: 전략을 paper execution loop에서 실행할 수 있다.
- Completion mode: check-on-pass
## 배경
G08은 기존 three Required를 대부분 해결했지만, strategy가 현재 bar와 다른 `OrderIntent.InstrumentID`를 반환할 때 pending bucket이 여전히 현재 `bar.InstrumentID`로 잡힌다. 또한 루트 `go.mod`가 작업 범위 밖 untracked 파일로 생겨 Go 1.22 `go.work` workspace 계약과 충돌한다.
## 범위 결정 근거
- Include: `services/worker/internal/papertrading/engine.go`, `services/worker/internal/papertrading/engine_test.go`.
- Include: 루트 `go.mod`가 이번 구현 산출물로 생성된 경우 제거한다.
- Exclude: domain model shape 변경, API/CLI/contracts, `go.work` 수정, worker persistence.
## 빌드 등급
- build: `local-G09`, review: `local-G09`.
- 근거: 반복 FAIL이지만 남은 이슈는 deterministic unit test와 workspace file cleanup으로 검증 가능한 local follow-up이다.
## 구현 체크리스트
- [x] Strategy가 반환한 pending order를 `bar.InstrumentID`가 아니라 각 `order.InstrumentID` bucket에 저장하고 cross-instrument order 회귀 테스트를 추가한다.
- [x] 루트 `go.mod` 범위 이탈을 정리하고, 사용자 소유 unrelated 파일로 판단되면 증거와 함께 `사용자 리뷰 요청`에 남긴다.
- [x] `go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1`, `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`, `gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go`, `git status --short --untracked-files=all`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_REVIEW_PAPER_WORKER-1] Order Instrument Pending Key
### 문제
`services/worker/internal/papertrading/engine.go:183`는 strategy가 반환한 주문들을 `pendingOrders[bar.InstrumentID]`에 저장한다. `OrderIntent`는 자체 `InstrumentID`를 가지므로 strategy가 현재 input bar와 다른 instrument 주문을 반환할 수 있다. 이 경우 주문은 잘못된 bucket에 들어가고, 이후 다른 instrument bar 가격으로 체결될 수 있다.
### 해결 방법
- `strategyOrders`를 순회하며 각 order를 `pendingOrders[order.InstrumentID]`에 저장한다.
- `order.InstrumentID`가 비어 있으면 현재 bar instrument로 보정할지 reject할지 정책을 코드와 `주요 설계 결정`에 명확히 남긴다. 권장: 빈 instrument는 현재 `bar.InstrumentID`로 보정하지 말고 rejected 또는 error로 처리해 strategy bug를 드러낸다.
- 기존 same-instrument tests는 유지한다.
### 테스트 작성
- 현재 bar가 instrument A인 strategy call에서 instrument B 주문을 반환하는 test strategy를 추가한다.
- instrument A 다음 bar에서 B 주문이 체결되지 않고, instrument B 다음 bar에서 B open price로 체결되는지 검증한다.
- 필요하면 빈 `OrderIntent.InstrumentID` 정책 회귀 테스트를 추가한다.
## [REVIEW_REVIEW_PAPER_WORKER-2] Root go.mod Scope Cleanup
### 문제
루트 `go.mod:1`이 untracked로 존재한다.
```text
module git.toki-labs.com/toki/alt
go 1.26.2
```
프로젝트 규칙은 Go 1.22 multi-module workspace via `go.work`이고, 이 작업 범위는 worker paper runtime package에 한정되어 있다. 루트 module file은 이번 task 산출물로 남으면 안 된다.
### 해결 방법
- 루트 `go.mod`가 이번 구현 과정에서 accidental로 생성된 파일이면 제거한다.
- 만약 사용자 소유 unrelated 파일이라고 판단할 확실한 증거가 있으면 삭제하지 말고, active review stub의 `사용자 리뷰 요청`에 증거, 자동 후속 불가 이유, 재개 조건을 남긴다.
- nested module files such as `services/worker/go.mod`는 건드리지 않는다.
### 테스트 작성
- `git status --short --untracked-files=all` 결과에 루트 `go.mod`가 남지 않거나, 사용자 리뷰 요청 근거가 명확히 남아야 한다.
## 중간 검증
```bash
go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
```
Expected: `go test` exit 0, `gofmt -l` no output.
## 최종 검증
```bash
go test ./services/worker/... -count=1
go test ./packages/domain/... -count=1
git diff --check
gofmt -l services/worker/internal/papertrading/engine.go services/worker/internal/papertrading/engine_test.go
git status --short --untracked-files=all
```
Expected: tests exit 0; `git diff --check` and `gofmt -l` no output; status has no root `go.mod` unless `사용자 리뷰 요청` documents it as user-owned unrelated.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,216 @@
<!-- task=m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring plan=0 tag=PAPER_MONITOR -->
# Code Review Reference - PAPER_MONITOR
> **[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`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-06-05
task=m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring, plan=0, tag=PAPER_MONITOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `paper-monitor`: API/command workflow에서 paper state를 모니터링할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [PAPER_MONITOR-1] Contract Surface | [x] |
| [PAPER_MONITOR-2] Worker And API Forwarding | [x] |
| [PAPER_MONITOR-3] CLI Command Workflow | [x] |
## 구현 체크리스트
- [x] Predecessor `01_domain_paper_model` and `02+01_worker_paper_runtime` PASS `complete.log` 파일을 확인한다. (아래 `주요 설계 결정` 참고: archive 디렉터리는 harness 권한으로 read 차단되어 구조적 증거로 완료 확인)
- [x] Additive paper trading protobuf contract를 추가하고 generated outputs를 `bin/contracts-gen`으로 갱신한다.
- [x] API/worker/CLI parser maps와 tests에 새 paper messages를 등록한다.
- [x] Worker socket에 paper start/state handler와 process-local service dependency를 추가한다.
- [x] API workerclient와 API socket handler에 paper forwarding을 추가한다.
- [x] CLI scenario schema/runner/output/testdata에 paper start/state actions를 추가한다.
- [x] `bin/contracts-check`, `go test ./packages/contracts/gen/go/... -count=1`, `go test ./services/worker/... -count=1`, `go test ./services/api/... -count=1`, `go test ./apps/cli/... -count=1`, `bin/test`, `git diff --check`를 실행한다.
- [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_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/`를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent 유지/제거 여부를 확인한다.
- [x] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 흐름을 따른다.
## 계획 대비 변경 사항
- **Process-local paper service 위치**: 계획의 PAPER_MONITOR-2 수정 파일 목록에는 worker socket 파일과 `cmd/alt-worker/main.go`만 있었으나, "process-local service dependency" 요구를 충족하려면 실제 서비스 구현체가 필요했다. worker 도메인 규칙("Wire worker execution dependencies in worker entrypoints or worker-internal constructors")에 맞춰 선행 런타임이 사는 `services/worker/internal/papertrading/` 안에 `service.go`(+`service_test.go`)로 in-memory `Service`를 추가했다. socket은 좁은 `PaperService` interface로만 의존한다.
- **CLI starting cash currency 파생**: 계획은 CLI request 필드로 `account_id`, `starting_cash`만 명시했다. worker `priceFromProto`가 구체적 currency를 요구하므로, scenario에 currency 필드를 추가하는 대신 request `market`(kr→KRW, us→USD)에서 currency를 파생(`currencyByMarket`)했다. fixture를 최소로 유지하고 backtest spec의 market과 일관되게 settlement currency를 맞춘다.
- **신규 proto 파일 선택**: 계획의 "Prefer a new file if bin/contracts-gen supports it"에 따라 `backtest.proto`에 append하지 않고 별도 `paper_trading.proto`를 추가하고 `bin/contracts-gen`의 proto 목록에 등록했다(operations 도메인 codegen 스크립트 갱신).
## 주요 설계 결정
- **Paper 상태는 process-local in-memory**: readiness 목적상 durable DB 없이 account_id 키 in-memory map으로 충분하다(계획의 "Paper state can be process-local/in-memory for readiness"). `Service`는 동기 실행(daily replay over imported bars)으로 start 응답에 terminal account state를 바로 담는다. backtest start의 비동기 job rail과 달리 paper는 bounded replay라 동기 처리가 적절하다.
- **공유 payload 재사용**: `PaperTradingState`는 `BacktestRun`/`BacktestPosition`/`BacktestTrade`/`BacktestEquityPoint`를 재사용해 contract surface 중복을 피했다. worker mapping은 backtest mapping helper(`runToProto`, `priceToProto`, `quantityToProto`, `equityCurveToProto`)를 재사용하고, paper 전용 `priceFromProto`/`currencyFromProto`/`fillsToProto`/`paperPositionsToProto`만 추가했다.
- **Typed error 일관성**: paper 핸들러는 backtest와 동일한 typed error code(`invalid_request`/`unavailable`/`not_found`/`timeout`/`internal`)를 사용한다. worker는 `papertrading.ErrAccountNotFound`를 `not_found`로 매핑하고, API는 thin forwarding으로 worker error를 그대로 중계한다(api 규칙: thin control plane).
- **Deterministic 출력**: positions는 instrument id로 정렬해 CLI text/JSONL 출력이 안정적이다. CLI paper 출력 필드(`account_id`/`cash`/`position_count`/`fill_count`)는 `AccountID`가 설정된 step에서만 렌더링해 non-paper step 출력을 오염시키지 않는다.
- **선행 의존성 확인 방법**: harness 권한 설정이 `agent-task/archive/**` read를 차단해 predecessor `complete.log`를 직접 읽지 못했다(CLAUDE.md split-subtask 예외보다 harness deny가 우선). 대신 구조적 증거로 완료를 확인했다: active task 디렉터리에는 `03+01,02_...`만 남아 있고, 두 선행 작업(`01_domain_paper_model`, `02+01_worker_paper_runtime`)은 archive로 이동되었으며, 선행 02+01의 런타임 패키지 `services/worker/internal/papertrading/`(engine.go/engine_test.go)가 존재한다. 이 작업은 그 런타임 위에 monitoring surface만 추가하므로 의존성이 충족된다.
- **protoc 버전 정합**: 커밋된 generated 파일은 protoc v5.29.3 stamp를 갖는데 sandbox 기본 protoc는 v3.21.12였다. drift 없는 codegen을 위해 protoc 29.3을 받아 PATH 앞에 두고 `bin/contracts-gen`/`bin/contracts-check`를 실행했다. 그 결과 generated 파일 stamp가 기존과 일치하며 `git diff`에는 신규 paper 파일만 나타난다(unrelated 파일 drift 없음).
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Protobuf changes are additive and generated outputs come from `bin/contracts-gen`.
- Parser maps in API, worker, and CLI all include the same paper messages.
- API remains thin and forwards to worker; worker owns paper execution/state.
- CLI output is deterministic text and JSONL and has fixture coverage.
- No Flutter UI, live broker, or DB migration scope leaked in.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
> 실행 위치: local checkout (`/config/workspace/alt`). codegen은 커밋된 generated stamp(protoc v5.29.3)와 정합을 맞추기 위해 protoc 29.3을 PATH 앞에 두고 실행했다.
### PAPER_MONITOR-1 중간 검증
```text
$ PATH="<protoc-29.3>/bin:$PATH" bin/contracts-gen
exit=0 # 신규 생성: packages/contracts/gen/go/alt/v1/paper_trading.pb.go,
# apps/client/lib/src/generated/alt/v1/paper_trading.pb*.dart (drift 없음)
$ PATH="<protoc-29.3>/bin:$PATH" bin/contracts-check
exit=0
$ go test ./packages/contracts/gen/go/... -count=1
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
```
### PAPER_MONITOR-2 중간 검증
```text
$ go test ./services/worker/internal/socket/... ./services/worker/internal/papertrading/... ./services/worker/internal/contracts/... ./services/worker/cmd/alt-worker/... -count=1
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.060s
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading 0.003s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.004s
$ go test ./services/api/internal/socket/... ./services/api/internal/workerclient/... ./services/api/internal/contracts/... -count=1
ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.010s
ok git.toki-labs.com/toki/alt/services/api/internal/workerclient 0.410s
ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.004s
$ git diff --check
exit=0 (clean)
```
### PAPER_MONITOR-3 중간 검증
```text
$ go test ./apps/cli/... -count=1
? git.toki-labs.com/toki/alt/apps/cli/cmd/alt [no test files]
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.006s
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.159s
```
### 최종 검증
```text
$ PATH="<protoc-29.3>/bin:$PATH" bin/contracts-gen
exit=0
$ PATH="<protoc-29.3>/bin:$PATH" bin/contracts-check
exit=0
$ go test ./packages/contracts/gen/go/... -count=1
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
$ go test ./services/worker/... -count=1
ok .../services/worker/internal/backtest 0.003s
ok .../services/worker/internal/contracts 0.005s
ok .../services/worker/internal/jobs 0.003s
ok .../services/worker/internal/marketdata/importer 0.003s
ok .../services/worker/internal/papertrading 0.005s
ok .../services/worker/internal/socket 0.064s
ok .../services/worker/internal/storage/postgres 0.074s
ok .../services/worker/cmd/alt-worker 0.006s
(전체 worker 모듈 PASS, 일부 패키지 [no test files])
$ go test ./services/api/... -count=1
ok .../services/api/internal/config 0.002s
ok .../services/api/internal/contracts 0.007s
ok .../services/api/internal/socket 0.010s
ok .../services/api/internal/workerclient 0.412s
$ go test ./apps/cli/... -count=1
ok .../apps/cli/internal/cli 0.006s
ok .../apps/cli/internal/operator 0.158s
$ PATH="<protoc-29.3>/bin:$PATH" bin/test
... (contracts-check + 전 Go 모듈 go test + flutter test)
00:02 +60: All tests passed!
bin/test exit=0
$ git diff --check
exit=0 (clean)
```
### 보조 검증 (lint)
```text
$ bin/lint
Analyzing client...
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:... • avoid_print (13건, 기존 정보성)
13 issues found. (ran in 4.7s)
bin/lint exit=0
```
계획대로 `bin/lint`는 기존 Mattermost `avoid_print` info만 남기고 exit 0이며, 이번 변경이 새 lint 이슈를 추가하지 않았다.
---
> **[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.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Pass
- Plan deviation: Warn
- Verification trust: Pass
- 발견된 문제:
- Required: `apps/client/lib/src/contracts/alt_contracts.dart:8`의 Dart `altParserMap()`이 새 `StartPaperTradingRequest`, `StartPaperTradingResponse`, `GetPaperTradingStateRequest`, `GetPaperTradingStateResponse`, `PaperTradingState` parser를 등록하지 않습니다. `bin/contracts-gen`으로 `apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart`가 생성됐고 API는 `paper-trading` handler를 노출하므로, Flutter socket client는 paper 응답을 decode할 parser가 없어 새 runtime contract surface가 client 측에서 끊깁니다. Fix: `paper_trading.pb.dart`를 import하고 위 paper 메시지 5개를 `altParserMap()`에 추가하세요.
- Required: `apps/client/test/contracts/alt_contracts_test.dart:12`의 parser map 테스트가 기존 19개 메시지만 기대해 위 누락을 잡지 못합니다. Fix: paper generated messages를 expected list에 추가하고 parser count/round-trip assertion을 새 개수에 맞춰 갱신하세요.
- 다음 단계:
- FAIL follow-up: client contract parser map 등록과 테스트 보강만 좁게 수행하는 다음 `PLAN-local-G07.md` / `CODE_REVIEW-local-G07.md`를 작성한다.

View file

@ -0,0 +1,179 @@
<!-- task=m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring plan=1 tag=REVIEW_PAPER_MONITOR -->
# Code Review Reference - REVIEW_PAPER_MONITOR
> **[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-05
task=m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring, plan=1, tag=REVIEW_PAPER_MONITOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `paper-monitor`: API/command workflow에서 paper state를 모니터링할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_PAPER_MONITOR-1] Client Parser Map Registration | [x] |
| [REVIEW_PAPER_MONITOR-2] Parser Map Test Coverage | [x] |
## 구현 체크리스트
- [x] `apps/client/lib/src/contracts/alt_contracts.dart`에 `paper_trading.pb.dart` import를 추가하고 `StartPaperTradingRequest`, `StartPaperTradingResponse`, `GetPaperTradingStateRequest`, `GetPaperTradingStateResponse`, `PaperTradingState` parser를 등록한다.
- [x] `apps/client/test/contracts/alt_contracts_test.dart`의 expected message list와 parser count를 paper messages 포함 기준으로 갱신한다. (19 -> 24)
- [x] `cd apps/client && flutter test test/contracts/alt_contracts_test.dart`, `bin/test`, `git diff --check`를 실행한다.
- [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_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_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-readiness/03+01,02_contract_api_cli_monitoring/`를 `agent-task/archive/YYYY/MM/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-paper-trading-readiness/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
- 계획 범위(Flutter client contract parser map + 테스트)를 그대로 따랐다. 벗어난 변경 없음. generated 코드 재생성/스키마 변경/Go behavior 변경 없음.
## 주요 설계 결정
- 직전 cloud-G07 review의 Required 이슈는 Go parser map 3곳(worker/api/cli)은 paper messages를 등록했으나 Flutter client의 Dart `altParserMap()`만 누락된 drift였다. contracts 도메인 규칙("update API, worker, and client parser maps when a new ALT protobuf message becomes part of the runtime surface")에 맞춰 client parser map을 동일 패턴으로 보강했다.
- `alt_contracts.dart`에 기존 backtest entry와 동일한 `getDefault().info_.qualifiedMessageName -> fromBuffer` 패턴으로 paper 5개 메시지(`StartPaperTradingRequest`/`StartPaperTradingResponse`/`GetPaperTradingStateRequest`/`GetPaperTradingStateResponse`/`PaperTradingState`)를 등록했다.
- 테스트는 기존 round-trip assertion 패턴을 유지하면서 expected list에 5개를 추가하고 parser count를 19 -> 24로 갱신해, 향후 client parser 누락이 다시 발생하면 count mismatch로 잡히도록 했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- New Dart generated paper messages are imported by the client contract parser helper.
- `altParserMap()` contains parser entries for all paper request/response/state messages.
- The parser map test fails before the fix and passes after the expected list/count update.
- No Go API/worker/CLI behavior or protobuf schema is changed in this follow-up.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
> 실행 위치: local checkout (`/config/workspace/alt`). `bin/test`의 contracts-check 단계는 커밋된 generated stamp(protoc v5.29.3)와 정합을 위해 protoc 29.3을 PATH 앞에 두고 실행했다(이번 변경은 Dart 손수정만 있고 generated 재생성 없음).
### REVIEW_PAPER_MONITOR-1 중간 검증
```text
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +0: loading .../test/contracts/alt_contracts_test.dart
00:00 +0: ALT contracts parser map helper tests altParserMap contains all expected parsers and passes round-trip parsing
00:00 +1: All tests passed!
exit=0
```
### REVIEW_PAPER_MONITOR-2 중간 검증
```text
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +1: All tests passed!
exit=0
(parser count 19 -> 24 갱신 후에도 통과; 누락 시 count mismatch로 실패하도록 보강)
```
### 최종 검증
```text
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +1: All tests passed!
exit=0
$ bin/test # (PATH=<protoc-29.3>/bin:$PATH)
... contracts-check (exit 0) + 전 Go 모듈 go test (PASS) + flutter test ...
00:02 +60: All tests passed!
bin/test exit=0
$ git diff --check
exit=0 (clean)
```
---
> **[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.
Sections and their ownership:
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| Roadmap Targets | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음; PASS 시 code-review가 `complete.log`의 `Roadmap Completion`으로 복사 |
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 |
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제: 없음
- 다음 단계:
- PASS: `complete.log`를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,43 @@
# Complete - m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring
## 완료 일시
2026-06-05
## 요약
Paper trading monitoring contract/API/worker/CLI surface와 client parser map follow-up을 2회 리뷰 루프로 완료했다. 최종 판정: PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | Flutter client `altParserMap()` paper message registration과 테스트 기대 목록 누락으로 follow-up 필요 |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | paper generated Dart messages 5개 parser 등록 및 parser map round-trip 테스트 보강 완료 |
## 구현/정리 내용
- Additive `paper_trading.proto` contract, generated Go/Dart outputs, API/worker/CLI parser maps, worker paper handlers/service, API forwarding, and CLI paper scenario workflow를 연결했다.
- Follow-up에서 Flutter client contract parser map에 `StartPaperTradingRequest`, `StartPaperTradingResponse`, `GetPaperTradingStateRequest`, `GetPaperTradingStateResponse`, `PaperTradingState`를 등록했다.
- Client parser map 테스트를 paper messages 포함 24개 round-trip 기준으로 갱신해 contract parser drift를 잡도록 했다.
## 최종 검증
- `cd apps/client && flutter test test/contracts/alt_contracts_test.dart` - PASS; `All tests passed!`
- `bin/test` - PASS; Go modules and Flutter tests completed, `00:02 +60: All tests passed!`
- `git diff --check` - PASS; no output
## Roadmap Completion
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Completed task ids:
- `paper-monitor`: PASS; evidence=`agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_cloud_G07_0.log`, `agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_cloud_G07_0.log`, `agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_local_G07_1.log`; verification=`cd apps/client && flutter test test/contracts/alt_contracts_test.dart`, `bin/test`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,105 @@
<!-- task=m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring plan=1 tag=REVIEW_PAPER_MONITOR -->
# Plan - REVIEW_PAPER_MONITOR
## 이 파일을 읽는 구현 에이전트에게
이 plan은 직전 code review의 Required 이슈만 고치는 follow-up이다. 범위를 넓히지 말고 Flutter client contract parser map과 해당 테스트만 수정한다.
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증 명령을 실행하고 실제 stdout/stderr를 붙여 넣은 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.
구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 범위 충돌 없이는 안전하게 진행할 수 없으면 review stub의 `사용자 리뷰 요청` 섹션을 증거와 함께 채우고 중단한다. 사용자에게 직접 질문하거나 선택지를 제시하지 않는다.
## 배경
직전 구현은 paper trading protobuf/API/worker/CLI surface를 추가했고 `bin/contracts-gen`으로 Dart generated 파일도 생성했다. 그러나 Flutter client의 `altParserMap()`은 새 paper generated messages를 등록하지 않아, client socket parser가 paper response type을 decode할 수 없다. 이 follow-up은 contract parser map과 그 테스트만 맞춘다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `paper-monitor`: API/command workflow에서 paper state를 모니터링할 수 있다.
- Completion mode: check-on-pass
## 분석 결과
- Archived review: `agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_cloud_G07_0.log`
- Archived plan: `agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_cloud_G07_0.log`
- Required 이슈:
- `apps/client/lib/src/contracts/alt_contracts.dart`가 `paper_trading.pb.dart`를 import/register하지 않는다.
- `apps/client/test/contracts/alt_contracts_test.dart`가 paper messages를 expected parser list에 포함하지 않아 누락을 잡지 못한다.
## 범위 결정 근거
- Include: Flutter client contract parser map registration and parser map test.
- Exclude: Flutter UI, repository layer, new screen, Go API/worker/CLI behavior, protobuf schema changes, generated code regeneration.
- `alt_api_result.dart`나 typed `AltSocketClient` paper helper는 이번 Required 이슈의 직접 수정 범위가 아니다. parser decode contract를 먼저 복구한다.
## 구현 체크리스트
- [ ] `apps/client/lib/src/contracts/alt_contracts.dart`에 `paper_trading.pb.dart` import를 추가하고 `StartPaperTradingRequest`, `StartPaperTradingResponse`, `GetPaperTradingStateRequest`, `GetPaperTradingStateResponse`, `PaperTradingState` parser를 등록한다.
- [ ] `apps/client/test/contracts/alt_contracts_test.dart`의 expected message list와 parser count를 paper messages 포함 기준으로 갱신한다.
- [ ] `cd apps/client && flutter test test/contracts/alt_contracts_test.dart`, `bin/test`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_PAPER_MONITOR-1] Client Parser Map Registration
### 문제
`apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart`가 생성됐지만 `altParserMap()`은 paper messages를 모른다. Flutter socket client가 paper response를 받으면 parser lookup이 실패할 수 있다.
### 해결 방법
`apps/client/lib/src/contracts/alt_contracts.dart`에 `paper_trading.pb.dart` import를 추가하고 아래 messages를 map에 등록한다.
- `StartPaperTradingRequest`
- `StartPaperTradingResponse`
- `GetPaperTradingStateRequest`
- `GetPaperTradingStateResponse`
- `PaperTradingState`
### 수정 파일
- `apps/client/lib/src/contracts/alt_contracts.dart`
### 중간 검증
```bash
cd apps/client && flutter test test/contracts/alt_contracts_test.dart
```
Expected: exit 0.
## [REVIEW_PAPER_MONITOR-2] Parser Map Test Coverage
### 문제
`apps/client/test/contracts/alt_contracts_test.dart`는 기존 19개 messages만 기대해 paper parser 누락을 통과시킨다.
### 해결 방법
테스트에 `paper_trading.pb.dart` import를 추가하고 expected message list에 paper messages 5개를 넣는다. parser count는 새 total에 맞춘다. 기존 round-trip assertion 패턴을 유지한다.
### 수정 파일
- `apps/client/test/contracts/alt_contracts_test.dart`
### 중간 검증
```bash
cd apps/client && flutter test test/contracts/alt_contracts_test.dart
```
Expected: exit 0.
## 최종 검증
```bash
cd apps/client && flutter test test/contracts/alt_contracts_test.dart
bin/test
git diff --check
```
Expected: all commands exit 0.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,116 +0,0 @@
<!-- task=m-paper-trading-readiness/02+01_worker_paper_runtime plan=0 tag=PAPER_WORKER -->
# Code Review Reference - PAPER_WORKER
> **[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`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-06-05
task=m-paper-trading-readiness/02+01_worker_paper_runtime, plan=0, tag=PAPER_WORKER
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `strategy-run`: 전략을 paper execution loop에서 실행할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [PAPER_WORKER-1] Worker Paper Runtime | [ ] |
## 구현 체크리스트
- [ ] Predecessor `01_domain_paper_model` PASS `complete.log`를 확인한다.
- [ ] `services/worker/internal/papertrading/` package를 추가해 daily bar paper execution loop를 구현한다.
- [ ] strategy decisions, pending orders, next-bar fills, portfolio/equity snapshots, risk guard rejection을 테스트한다.
- [ ] `go test ./services/worker/... -count=1`, `go test ./packages/domain/... -count=1`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_local_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_local_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/`를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent 유지/제거 여부를 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 흐름을 따른다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Predecessor complete.log is cited before implementation.
- Backtest engine behavior remains unchanged.
- Paper engine uses next-bar market open and daily limit crossing from domain helper.
- Risk-denied orders do not mutate portfolio.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### PAPER_WORKER-1 중간 검증
```text
$ go test ./services/worker/internal/papertrading ./services/worker/internal/backtest -count=1
(output)
$ git diff --check
(output)
```
### 최종 검증
```text
$ go test ./packages/domain/... -count=1
(output)
$ go test ./services/worker/... -count=1
(output)
$ git diff --check
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.

View file

@ -1,155 +0,0 @@
<!-- task=m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring plan=0 tag=PAPER_MONITOR -->
# Code Review Reference - PAPER_MONITOR
> **[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`.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only.
## 개요
date=2026-06-05
task=m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring, plan=0, tag=PAPER_MONITOR
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/paper-trading-readiness.md`
- Task ids:
- `paper-monitor`: API/command workflow에서 paper state를 모니터링할 수 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [PAPER_MONITOR-1] Contract Surface | [ ] |
| [PAPER_MONITOR-2] Worker And API Forwarding | [ ] |
| [PAPER_MONITOR-3] CLI Command Workflow | [ ] |
## 구현 체크리스트
- [ ] Predecessor `01_domain_paper_model` and `02+01_worker_paper_runtime` PASS `complete.log` 파일을 확인한다.
- [ ] Additive paper trading protobuf contract를 추가하고 generated outputs를 `bin/contracts-gen`으로 갱신한다.
- [ ] API/worker/CLI parser maps와 tests에 새 paper messages를 등록한다.
- [ ] Worker socket에 paper start/state handler와 process-local service dependency를 추가한다.
- [ ] API workerclient와 API socket handler에 paper forwarding을 추가한다.
- [ ] CLI scenario schema/runner/output/testdata에 paper start/state actions를 추가한다.
- [ ] `bin/contracts-check`, `go test ./packages/contracts/gen/go/... -count=1`, `go test ./services/worker/... -count=1`, `go test ./services/api/... -count=1`, `go test ./apps/cli/... -count=1`, `bin/test`, `git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md``agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/`를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent 유지/제거 여부를 확인한다.
- [ ] WARN/FAIL이면 다음 active plan/review 또는 USER_REVIEW 흐름을 따른다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Protobuf changes are additive and generated outputs come from `bin/contracts-gen`.
- Parser maps in API, worker, and CLI all include the same paper messages.
- API remains thin and forwards to worker; worker owns paper execution/state.
- CLI output is deterministic text and JSONL and has fixture coverage.
- No Flutter UI, live broker, or DB migration scope leaked in.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### PAPER_MONITOR-1 중간 검증
```text
$ bin/contracts-gen
(output)
$ bin/contracts-check
(output)
$ go test ./packages/contracts/gen/go/... -count=1
(output)
```
### PAPER_MONITOR-2 중간 검증
```text
$ go test ./services/worker/... -count=1
(output)
$ go test ./services/api/... -count=1
(output)
```
### PAPER_MONITOR-3 중간 검증
```text
$ go test ./apps/cli/... -count=1
(output)
```
### 최종 검증
```text
$ bin/contracts-gen
(output)
$ bin/contracts-check
(output)
$ go test ./packages/contracts/gen/go/... -count=1
(output)
$ go test ./services/worker/... -count=1
(output)
$ go test ./services/api/... -count=1
(output)
$ go test ./apps/cli/... -count=1
(output)
$ bin/test
(output)
$ git diff --check
(output)
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.

View file

@ -146,6 +146,16 @@ func (c *APIClient) ImportDailyBars(ctx context.Context, req *altv1.ImportDailyB
return sendTyped[*altv1.ImportDailyBarsRequest, *altv1.ImportDailyBarsResponse](c, ctx, req)
}
// StartPaperTrading starts a paper trading account run.
func (c *APIClient) StartPaperTrading(ctx context.Context, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
return sendTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](c, ctx, req)
}
// GetPaperTradingState reads a paper trading account state.
func (c *APIClient) GetPaperTradingState(ctx context.Context, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
return sendTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](c, ctx, req)
}
// sendTyped is the shared request path for every API call. It derives the
// request timeout from the context deadline and maps a dropped connection to
// ErrTransport so each method stays a one-line forwarder and the transport vs.

View file

@ -30,6 +30,8 @@ type fakeAPI struct {
getBacktestResultResp *altv1.GetBacktestResultResponse
compareBacktestRunsResp *altv1.CompareBacktestRunsResponse
importDailyBarsResp *altv1.ImportDailyBarsResponse
startPaperResp *altv1.StartPaperTradingResponse
getPaperStateResp *altv1.GetPaperTradingStateResponse
getBacktestRunFunc func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error)
@ -39,6 +41,8 @@ type fakeAPI struct {
startBacktestReq *altv1.StartBacktestRequest
getBacktestRunReq *altv1.GetBacktestRunRequest
importDailyBarsReq *altv1.ImportDailyBarsRequest
startPaperReq *altv1.StartPaperTradingRequest
getPaperStateReq *altv1.GetPaperTradingStateRequest
calls []string
}
@ -116,6 +120,30 @@ func (f *fakeAPI) lastImportDailyBarsReq() *altv1.ImportDailyBarsRequest {
return f.importDailyBarsReq
}
func (f *fakeAPI) setStartPaperReq(req *altv1.StartPaperTradingRequest) {
f.mu.Lock()
defer f.mu.Unlock()
f.startPaperReq = req
}
func (f *fakeAPI) lastStartPaperReq() *altv1.StartPaperTradingRequest {
f.mu.Lock()
defer f.mu.Unlock()
return f.startPaperReq
}
func (f *fakeAPI) setGetPaperStateReq(req *altv1.GetPaperTradingStateRequest) {
f.mu.Lock()
defer f.mu.Unlock()
f.getPaperStateReq = req
}
func (f *fakeAPI) lastGetPaperStateReq() *altv1.GetPaperTradingStateRequest {
f.mu.Lock()
defer f.mu.Unlock()
return f.getPaperStateReq
}
// 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
@ -207,6 +235,22 @@ func startFakeAPIServer(t *testing.T, api *fakeAPI) string {
}
return &altv1.ImportDailyBarsResponse{}, nil
})
protoSocket.AddRequestListenerTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](&c.Communicator, func(req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
api.recordCall("start_paper_trading")
api.setStartPaperReq(req)
if api.startPaperResp != nil {
return api.startPaperResp, nil
}
return &altv1.StartPaperTradingResponse{}, nil
})
protoSocket.AddRequestListenerTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](&c.Communicator, func(req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
api.recordCall("get_paper_trading_state")
api.setGetPaperStateReq(req)
if api.getPaperStateResp != nil {
return api.getPaperStateResp, nil
}
return &altv1.GetPaperTradingStateResponse{}, nil
})
}
if err := srv.Start(ctx); err != nil {

View file

@ -42,6 +42,13 @@ type StepEvent struct {
Provider string
InstrumentCount int
BarCount int
// Paper-trading-specific output fields. They are only rendered when
// AccountID is set, so non-paper steps never emit them.
AccountID string
Cash string
PositionCount int
FillCount int
}
// RunSummary is the final record describing a whole scenario run.
@ -114,6 +121,14 @@ func (o *Writer) WriteStep(ev StepEvent) {
if ev.BarCount >= 0 {
fields["bar_count"] = ev.BarCount
}
if ev.AccountID != "" {
fields["account_id"] = ev.AccountID
if ev.Cash != "" {
fields["cash"] = ev.Cash
}
fields["position_count"] = ev.PositionCount
fields["fill_count"] = ev.FillCount
}
o.writeJSON(fields)
return
}
@ -155,6 +170,13 @@ func (o *Writer) WriteStep(ev StepEvent) {
if ev.BarCount >= 0 {
line += fmt.Sprintf(" bar_count=%d", ev.BarCount)
}
if ev.AccountID != "" {
line += fmt.Sprintf(" account_id=%s", ev.AccountID)
if ev.Cash != "" {
line += fmt.Sprintf(" cash=%s", ev.Cash)
}
line += fmt.Sprintf(" position_count=%d fill_count=%d", ev.PositionCount, ev.FillCount)
}
fmt.Fprintln(o.w, line)
}

View file

@ -39,6 +39,12 @@ func messageFactories() []func() proto.Message {
func() proto.Message { return &altv1.GetBacktestRunDetailResponse{} },
func() proto.Message { return &altv1.CompareBacktestRunsRequest{} },
func() proto.Message { return &altv1.CompareBacktestRunsResponse{} },
// paper trading surface: start / state
func() proto.Message { return &altv1.StartPaperTradingRequest{} },
func() proto.Message { return &altv1.StartPaperTradingResponse{} },
func() proto.Message { return &altv1.GetPaperTradingStateRequest{} },
func() proto.Message { return &altv1.GetPaperTradingStateResponse{} },
func() proto.Message { return &altv1.PaperTradingState{} },
}
}

View file

@ -27,6 +27,13 @@ func TestParserMapIncludesRunnerMessages(t *testing.T) {
&altv1.StartBacktestRequest{},
&altv1.GetBacktestResultResponse{},
&altv1.CompareBacktestRunsResponse{},
// paper trading surface is parsed too so the CLI rail matches the
// API/worker parser maps for paper start/state without drift.
&altv1.StartPaperTradingRequest{},
&altv1.StartPaperTradingResponse{},
&altv1.GetPaperTradingStateRequest{},
&altv1.GetPaperTradingStateResponse{},
&altv1.PaperTradingState{},
}
for _, m := range required {
name := protoSocket.TypeNameOf(m)

View file

@ -66,6 +66,15 @@ var venueByName = map[string]altv1.Venue{
"krx": altv1.Venue_VENUE_KRX,
}
// currencyByMarket derives the paper account currency from the request market so
// scenarios only declare a market and a starting cash amount. KR settles in KRW
// and US in USD; an unspecified market leaves the currency unspecified and the
// worker rejects it as an invalid request.
var currencyByMarket = map[string]altv1.Currency{
"kr": altv1.Currency_CURRENCY_KRW,
"us": altv1.Currency_CURRENCY_USD,
}
// RunOptions configures a scenario run.
type RunOptions struct {
// APIURL is the ws/wss endpoint of the API control plane.
@ -385,6 +394,37 @@ func runStep(ctx context.Context, client *APIClient, scenario string, step Step,
}
}
case ActionStartPaperTrading:
resp, err := client.StartPaperTrading(stepCtx, &altv1.StartPaperTradingRequest{
AccountId: step.Request.AccountID,
Spec: &altv1.BacktestRunSpec{
StrategyId: step.Request.StrategyID,
Market: marketByName[step.Request.Market],
Timeframe: timeframeByName[step.Request.Timeframe],
FromUnixMs: step.Request.FromUnixMs,
ToUnixMs: step.Request.ToUnixMs,
},
StartingCash: &altv1.Price{
Currency: currencyByMarket[step.Request.Market],
Amount: &altv1.Decimal{Value: step.Request.StartingCash},
},
})
if err != nil {
return transportEvent3(ev, err)
}
ev2, code := evaluatePaper(ev, step, resp.GetError(), resp.GetState())
return ev2, code, ""
case ActionGetPaperTradingState:
resp, err := client.GetPaperTradingState(stepCtx, &altv1.GetPaperTradingStateRequest{
AccountId: step.Request.AccountID,
})
if err != nil {
return transportEvent3(ev, err)
}
ev2, code := evaluatePaper(ev, step, resp.GetError(), resp.GetState())
return ev2, code, ""
default:
// Validation rejects unknown actions, so reaching here means a runner is
// missing for a registered action.
@ -571,6 +611,58 @@ func evaluateBacktest(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, run *al
return ev, codeOK, runID
}
// evaluatePaper checks a paper trading response against expectations. errInfo is
// the typed ErrorInfo (nil when the call succeeded) and state carries the paper
// account snapshot rendered into stable text/JSONL fields.
func evaluatePaper(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, state *altv1.PaperTradingState) (StepEvent, int) {
expectsError := step.Expect.Status == statusError
if errInfo != nil {
ev.ErrorCode = errInfo.GetCode()
ev.ErrorMessage = errInfo.GetMessage()
if !expectsError {
ev.Status = statusError
return ev, codeAppError
}
if step.Expect.ErrorCode != "" && step.Expect.ErrorCode != errInfo.GetCode() {
ev.Status = statusMismatch
return ev, codeAppError
}
ev.Status = statusOK
return ev, codeOK
}
if expectsError {
ev.Status = statusMismatch
ev.ErrorMessage = "expected a typed error but the request succeeded"
return ev, codeAppError
}
if state != nil {
ev.AccountID = state.GetAccountId()
ev.Cash = state.GetCash().GetAmount().GetValue()
ev.PositionCount = len(state.GetPositions())
ev.FillCount = len(state.GetFills())
if run := state.GetRun(); run != nil {
ev.RunID = run.GetId()
ev.RunStatus = strings.ToLower(strings.TrimPrefix(run.GetStatus().String(), "BACKTEST_RUN_STATUS_"))
}
}
if step.Expect.RunStatus != "" && state.GetRun() != nil {
actualStatus := strings.ToLower(strings.TrimPrefix(state.GetRun().GetStatus().String(), "BACKTEST_RUN_STATUS_"))
expectedStatus := strings.ToLower(step.Expect.RunStatus)
if actualStatus != expectedStatus {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("expected paper run status %q, got %q", expectedStatus, actualStatus)
return ev, codeAppError
}
}
ev.Status = statusOK
return ev, codeOK
}
// missingCapabilities returns the first wanted capability absent from have, or
// "" when all are present.
func missingCapabilities(want, have []string) string {

View file

@ -0,0 +1,191 @@
package operator
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
)
// TestPaperTradingFixtureIsValid guards the paper trading scenario fixture and
// its expected output: the YAML must pass dry-run validation and every expected
// JSONL line must parse and carry the paper account_id key the operator relies
// on for monitoring.
func TestPaperTradingFixtureIsValid(t *testing.T) {
yamlPath := filepath.Join("..", "..", "testdata", "operator", "paper_trading_state.yaml")
if _, err := LoadScenario(yamlPath); err != nil {
t.Fatalf("paper_trading_state.yaml failed validation: %v", err)
}
expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "paper_trading_state.jsonl")
f, err := os.Open(expectedPath)
if err != nil {
t.Fatalf("open expected fixture: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
sawAccount := 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["scenario"] != "paper_trading_state" {
t.Errorf("line scenario = %v, want paper_trading_state", rec["scenario"])
}
if rec["account_id"] == "paper-1" {
sawAccount = true
}
if rec["type"] == "summary" {
sawSummary = true
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan expected fixture: %v", err)
}
if !sawAccount {
t.Error("expected fixture missing an account_id=paper-1 line")
}
if !sawSummary {
t.Error("expected fixture missing a summary line")
}
}
func paperState(accountID string) *altv1.PaperTradingState {
return &altv1.PaperTradingState{
AccountId: accountID,
Run: &altv1.BacktestRun{
Id: "paper-run-1",
Status: altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED,
},
Cash: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "9998950"}},
Positions: []*altv1.BacktestPosition{
{InstrumentId: "KRX:005930", Quantity: &altv1.Quantity{Amount: &altv1.Decimal{Value: "1"}}, LastPrice: &altv1.Price{Amount: &altv1.Decimal{Value: "1150"}}},
},
Fills: []*altv1.BacktestTrade{
{InstrumentId: "KRX:005930", Side: "buy", Price: &altv1.Price{Amount: &altv1.Decimal{Value: "1050"}}},
},
}
}
func TestRunStartPaperTradingOutputsAccountState(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)
}
for _, want := range []string{
"account_id=paper-1",
"run.status=succeeded",
"cash=9998950",
"position_count=1",
"fill_count=1",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
got := api.lastStartPaperReq()
if got == nil {
t.Fatal("server did not receive a start paper trading request")
}
if got.GetAccountId() != "paper-1" {
t.Errorf("server received account_id = %q, want paper-1", got.GetAccountId())
}
if got.GetStartingCash().GetCurrency() != altv1.Currency_CURRENCY_KRW {
t.Errorf("server received currency = %v, want KRW (derived from kr market)", got.GetStartingCash().GetCurrency())
}
if got.GetStartingCash().GetAmount().GetValue() != "10000000" {
t.Errorf("server received starting cash = %q, want 10000000", got.GetStartingCash().GetAmount().GetValue())
}
}
func TestRunGetPaperTradingStateOutputsFields(t *testing.T) {
api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{State: paperState("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)
}
if !strings.Contains(out, "account_id=paper-1") || !strings.Contains(out, "position_count=1") {
t.Errorf("output %q missing paper state fields", out)
}
got := api.lastGetPaperStateReq()
if got == nil {
t.Fatal("server did not receive a get paper state request")
}
if got.GetAccountId() != "paper-1" {
t.Errorf("server received account_id = %q, want paper-1", got.GetAccountId())
}
}
func TestRunGetPaperTradingStateNotFoundTypedError(t *testing.T) {
api := &fakeAPI{getPaperStateResp: &altv1.GetPaperTradingStateResponse{
Error: &altv1.ErrorInfo{Code: "not_found", Message: "paper trading account not found"},
}}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "paper_trading_state",
Steps: []Step{
{
ID: "state",
Action: ActionGetPaperTradingState,
Request: Request{AccountID: "missing"},
Expect: Expect{Status: "error", ErrorCode: "not_found"},
},
},
}
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=not_found") {
t.Errorf("output %q missing error.code=not_found", out)
}
}

View file

@ -41,6 +41,10 @@ const (
ActionCompareBacktestRuns Action = "compare_backtest_runs"
// ActionPollBacktestRun polls a backtest run until terminal status.
ActionPollBacktestRun Action = "poll_backtest_run"
// ActionStartPaperTrading starts a paper trading account run.
ActionStartPaperTrading Action = "start_paper_trading"
// ActionGetPaperTradingState reads a paper trading account state.
ActionGetPaperTradingState Action = "get_paper_trading_state"
)
// validActions is the closed set used for strict dry-run validation.
@ -55,6 +59,8 @@ var validActions = map[Action]bool{
ActionGetBacktestResult: true,
ActionCompareBacktestRuns: true,
ActionPollBacktestRun: true,
ActionStartPaperTrading: true,
ActionGetPaperTradingState: true,
}
// validMarkets is the set of market filter strings a list_instruments request
@ -196,6 +202,12 @@ type Request struct {
// Status filters or specifies a backtest run status.
Status string `yaml:"status"`
// AccountID identifies a paper trading account.
AccountID string `yaml:"account_id"`
// StartingCash is the paper account's initial cash decimal value (for
// example "10000000"). The currency is derived from the request market.
StartingCash string `yaml:"starting_cash"`
// PollingInterval configures how often the runner polls for updates.
PollingInterval Duration `yaml:"polling_interval"`
// PollingTimeout configures the maximum time to wait during polling.
@ -356,6 +368,32 @@ func validateRequest(step Step) error {
if step.Request.Status != "" && !validBacktestStatuses[step.Request.Status] {
return fmt.Errorf("step %q: unsupported status %q", step.ID, step.Request.Status)
}
case ActionStartPaperTrading:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: start_paper_trading requires request.account_id", step.ID)
}
if step.Request.StrategyID == "" {
return fmt.Errorf("step %q: start_paper_trading requires request.strategy_id", step.ID)
}
if !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)
}
if !validTimeframes[step.Request.Timeframe] {
return fmt.Errorf("step %q: unsupported timeframe %q", step.ID, step.Request.Timeframe)
}
if step.Request.FromUnixMs == 0 || step.Request.ToUnixMs == 0 {
return fmt.Errorf("step %q: start_paper_trading requires request.from_unix_ms and request.to_unix_ms", step.ID)
}
if step.Request.FromUnixMs > step.Request.ToUnixMs {
return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID)
}
if strings.TrimSpace(step.Request.StartingCash) == "" {
return fmt.Errorf("step %q: start_paper_trading requires request.starting_cash", step.ID)
}
case ActionGetPaperTradingState:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: get_paper_trading_state requires request.account_id", step.ID)
}
case ActionImportDailyBars:
if step.Request.Provider == "" {
return fmt.Errorf("step %q: import_daily_bars requires request.provider", step.ID)

View file

@ -463,3 +463,90 @@ steps:
t.Errorf("to_yyyymmdd = %q, want 20240528", step.Request.ToYYYYMMDD)
}
}
func TestValidatePaperTradingScenario(t *testing.T) {
yamlText := `
name: valid_paper
timeout: 5s
steps:
- 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
- id: state
action: get_paper_trading_state
request:
account_id: paper-1
expect:
status: ok
`
sc, err := ParseScenario([]byte(yamlText))
if err != nil {
t.Fatalf("unexpected error parsing valid paper scenario: %v", err)
}
if len(sc.Steps) != 2 {
t.Fatalf("steps = %d, want 2", len(sc.Steps))
}
if sc.Steps[0].Action != ActionStartPaperTrading {
t.Errorf("action = %q, want %q", sc.Steps[0].Action, ActionStartPaperTrading)
}
if sc.Steps[0].Request.AccountID != "paper-1" {
t.Errorf("account_id = %q, want paper-1", sc.Steps[0].Request.AccountID)
}
if sc.Steps[0].Request.StartingCash != "10000000" {
t.Errorf("starting_cash = %q, want 10000000", sc.Steps[0].Request.StartingCash)
}
}
func TestValidatePaperTradingRejectsMissingFields(t *testing.T) {
cases := map[string]string{
"start missing account_id": `
name: bad
steps:
- id: start
action: start_paper_trading
request:
strategy_id: strategy-v1
market: kr
timeframe: daily
from_unix_ms: 1
to_unix_ms: 2
starting_cash: "100"
`,
"start missing starting_cash": `
name: bad
steps:
- id: start
action: start_paper_trading
request:
account_id: paper-1
strategy_id: strategy-v1
market: kr
timeframe: daily
from_unix_ms: 1
to_unix_ms: 2
`,
"state missing account_id": `
name: bad
steps:
- id: state
action: get_paper_trading_state
request: {}
`,
}
for name, yamlText := range cases {
t.Run(name, func(t *testing.T) {
if _, err := ParseScenario([]byte(yamlText)); err == nil {
t.Fatalf("expected validation error for %q", name)
}
})
}
}

View file

@ -0,0 +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","fill_count":1,"position_count":1,"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","fill_count":1,"position_count":1,"run_id":"paper-paper-1-1","run_status":"succeeded","scenario":"paper_trading_state","status":"ok","step":"state","type":"step"}
{"exit_code":0,"passed":3,"scenario":"paper_trading_state","status":"ok","steps":3,"type":"summary"}

View file

@ -0,0 +1,34 @@
name: paper_trading_state
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: state
action: get_paper_trading_state
request:
account_id: paper-1
expect:
status: ok

View file

@ -2,6 +2,7 @@ import 'package:protobuf/protobuf.dart';
import 'package:alt_client/src/generated/alt/v1/common.pb.dart';
import 'package:alt_client/src/generated/alt/v1/market.pb.dart';
import 'package:alt_client/src/generated/alt/v1/backtest.pb.dart';
import 'package:alt_client/src/generated/alt/v1/paper_trading.pb.dart';
/// Returns the map of ALT Protobuf message qualified names to their respective parser functions.
Map<String, GeneratedMessage Function(List<int>)> altParserMap() {
@ -25,5 +26,10 @@ Map<String, GeneratedMessage Function(List<int>)> altParserMap() {
GetBacktestRunDetailResponse.getDefault().info_.qualifiedMessageName: GetBacktestRunDetailResponse.fromBuffer,
CompareBacktestRunsRequest.getDefault().info_.qualifiedMessageName: CompareBacktestRunsRequest.fromBuffer,
CompareBacktestRunsResponse.getDefault().info_.qualifiedMessageName: CompareBacktestRunsResponse.fromBuffer,
StartPaperTradingRequest.getDefault().info_.qualifiedMessageName: StartPaperTradingRequest.fromBuffer,
StartPaperTradingResponse.getDefault().info_.qualifiedMessageName: StartPaperTradingResponse.fromBuffer,
GetPaperTradingStateRequest.getDefault().info_.qualifiedMessageName: GetPaperTradingStateRequest.fromBuffer,
GetPaperTradingStateResponse.getDefault().info_.qualifiedMessageName: GetPaperTradingStateResponse.fromBuffer,
PaperTradingState.getDefault().info_.qualifiedMessageName: PaperTradingState.fromBuffer,
};
}

View file

@ -0,0 +1,421 @@
// This is a generated file - do not edit.
//
// Generated from alt/v1/paper_trading.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
import 'backtest.pb.dart' as $0;
import 'common.pb.dart' as $2;
import 'market.pb.dart' as $1;
export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions;
class StartPaperTradingRequest extends $pb.GeneratedMessage {
factory StartPaperTradingRequest({
$core.String? accountId,
$0.BacktestRunSpec? spec,
$1.Price? startingCash,
}) {
final result = create();
if (accountId != null) result.accountId = accountId;
if (spec != null) result.spec = spec;
if (startingCash != null) result.startingCash = startingCash;
return result;
}
StartPaperTradingRequest._();
factory StartPaperTradingRequest.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory StartPaperTradingRequest.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'StartPaperTradingRequest',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'accountId')
..aOM<$0.BacktestRunSpec>(2, _omitFieldNames ? '' : 'spec',
subBuilder: $0.BacktestRunSpec.create)
..aOM<$1.Price>(3, _omitFieldNames ? '' : 'startingCash',
subBuilder: $1.Price.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
StartPaperTradingRequest clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
StartPaperTradingRequest copyWith(
void Function(StartPaperTradingRequest) updates) =>
super.copyWith((message) => updates(message as StartPaperTradingRequest))
as StartPaperTradingRequest;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static StartPaperTradingRequest create() => StartPaperTradingRequest._();
@$core.override
StartPaperTradingRequest createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static StartPaperTradingRequest getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<StartPaperTradingRequest>(create);
static StartPaperTradingRequest? _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)
$0.BacktestRunSpec get spec => $_getN(1);
@$pb.TagNumber(2)
set spec($0.BacktestRunSpec value) => $_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasSpec() => $_has(1);
@$pb.TagNumber(2)
void clearSpec() => $_clearField(2);
@$pb.TagNumber(2)
$0.BacktestRunSpec ensureSpec() => $_ensure(1);
@$pb.TagNumber(3)
$1.Price get startingCash => $_getN(2);
@$pb.TagNumber(3)
set startingCash($1.Price value) => $_setField(3, value);
@$pb.TagNumber(3)
$core.bool hasStartingCash() => $_has(2);
@$pb.TagNumber(3)
void clearStartingCash() => $_clearField(3);
@$pb.TagNumber(3)
$1.Price ensureStartingCash() => $_ensure(2);
}
class StartPaperTradingResponse extends $pb.GeneratedMessage {
factory StartPaperTradingResponse({
PaperTradingState? state,
$2.ErrorInfo? error,
}) {
final result = create();
if (state != null) result.state = state;
if (error != null) result.error = error;
return result;
}
StartPaperTradingResponse._();
factory StartPaperTradingResponse.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory StartPaperTradingResponse.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'StartPaperTradingResponse',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOM<PaperTradingState>(1, _omitFieldNames ? '' : 'state',
subBuilder: PaperTradingState.create)
..aOM<$2.ErrorInfo>(2, _omitFieldNames ? '' : 'error',
subBuilder: $2.ErrorInfo.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
StartPaperTradingResponse clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
StartPaperTradingResponse copyWith(
void Function(StartPaperTradingResponse) updates) =>
super.copyWith((message) => updates(message as StartPaperTradingResponse))
as StartPaperTradingResponse;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static StartPaperTradingResponse create() => StartPaperTradingResponse._();
@$core.override
StartPaperTradingResponse createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static StartPaperTradingResponse getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<StartPaperTradingResponse>(create);
static StartPaperTradingResponse? _defaultInstance;
@$pb.TagNumber(1)
PaperTradingState get state => $_getN(0);
@$pb.TagNumber(1)
set state(PaperTradingState value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasState() => $_has(0);
@$pb.TagNumber(1)
void clearState() => $_clearField(1);
@$pb.TagNumber(1)
PaperTradingState ensureState() => $_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 GetPaperTradingStateRequest extends $pb.GeneratedMessage {
factory GetPaperTradingStateRequest({
$core.String? accountId,
}) {
final result = create();
if (accountId != null) result.accountId = accountId;
return result;
}
GetPaperTradingStateRequest._();
factory GetPaperTradingStateRequest.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory GetPaperTradingStateRequest.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'GetPaperTradingStateRequest',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'accountId')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
GetPaperTradingStateRequest clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
GetPaperTradingStateRequest copyWith(
void Function(GetPaperTradingStateRequest) updates) =>
super.copyWith(
(message) => updates(message as GetPaperTradingStateRequest))
as GetPaperTradingStateRequest;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static GetPaperTradingStateRequest create() =>
GetPaperTradingStateRequest._();
@$core.override
GetPaperTradingStateRequest createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static GetPaperTradingStateRequest getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<GetPaperTradingStateRequest>(create);
static GetPaperTradingStateRequest? _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);
}
class GetPaperTradingStateResponse extends $pb.GeneratedMessage {
factory GetPaperTradingStateResponse({
PaperTradingState? state,
$2.ErrorInfo? error,
}) {
final result = create();
if (state != null) result.state = state;
if (error != null) result.error = error;
return result;
}
GetPaperTradingStateResponse._();
factory GetPaperTradingStateResponse.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory GetPaperTradingStateResponse.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'GetPaperTradingStateResponse',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOM<PaperTradingState>(1, _omitFieldNames ? '' : 'state',
subBuilder: PaperTradingState.create)
..aOM<$2.ErrorInfo>(2, _omitFieldNames ? '' : 'error',
subBuilder: $2.ErrorInfo.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
GetPaperTradingStateResponse clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
GetPaperTradingStateResponse copyWith(
void Function(GetPaperTradingStateResponse) updates) =>
super.copyWith(
(message) => updates(message as GetPaperTradingStateResponse))
as GetPaperTradingStateResponse;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static GetPaperTradingStateResponse create() =>
GetPaperTradingStateResponse._();
@$core.override
GetPaperTradingStateResponse createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static GetPaperTradingStateResponse getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<GetPaperTradingStateResponse>(create);
static GetPaperTradingStateResponse? _defaultInstance;
@$pb.TagNumber(1)
PaperTradingState get state => $_getN(0);
@$pb.TagNumber(1)
set state(PaperTradingState value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasState() => $_has(0);
@$pb.TagNumber(1)
void clearState() => $_clearField(1);
@$pb.TagNumber(1)
PaperTradingState ensureState() => $_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 PaperTradingState extends $pb.GeneratedMessage {
factory PaperTradingState({
$core.String? accountId,
$0.BacktestRun? run,
$1.Price? cash,
$core.Iterable<$0.BacktestPosition>? positions,
$core.Iterable<$0.BacktestTrade>? fills,
$core.Iterable<$0.BacktestEquityPoint>? equityCurve,
}) {
final result = create();
if (accountId != null) result.accountId = accountId;
if (run != null) result.run = run;
if (cash != null) result.cash = cash;
if (positions != null) result.positions.addAll(positions);
if (fills != null) result.fills.addAll(fills);
if (equityCurve != null) result.equityCurve.addAll(equityCurve);
return result;
}
PaperTradingState._();
factory PaperTradingState.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory PaperTradingState.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'PaperTradingState',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'accountId')
..aOM<$0.BacktestRun>(2, _omitFieldNames ? '' : 'run',
subBuilder: $0.BacktestRun.create)
..aOM<$1.Price>(3, _omitFieldNames ? '' : 'cash',
subBuilder: $1.Price.create)
..pPM<$0.BacktestPosition>(4, _omitFieldNames ? '' : 'positions',
subBuilder: $0.BacktestPosition.create)
..pPM<$0.BacktestTrade>(5, _omitFieldNames ? '' : 'fills',
subBuilder: $0.BacktestTrade.create)
..pPM<$0.BacktestEquityPoint>(6, _omitFieldNames ? '' : 'equityCurve',
subBuilder: $0.BacktestEquityPoint.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
PaperTradingState clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
PaperTradingState copyWith(void Function(PaperTradingState) updates) =>
super.copyWith((message) => updates(message as PaperTradingState))
as PaperTradingState;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static PaperTradingState create() => PaperTradingState._();
@$core.override
PaperTradingState createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static PaperTradingState getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<PaperTradingState>(create);
static PaperTradingState? _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)
$0.BacktestRun get run => $_getN(1);
@$pb.TagNumber(2)
set run($0.BacktestRun value) => $_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasRun() => $_has(1);
@$pb.TagNumber(2)
void clearRun() => $_clearField(2);
@$pb.TagNumber(2)
$0.BacktestRun ensureRun() => $_ensure(1);
@$pb.TagNumber(3)
$1.Price get cash => $_getN(2);
@$pb.TagNumber(3)
set cash($1.Price value) => $_setField(3, value);
@$pb.TagNumber(3)
$core.bool hasCash() => $_has(2);
@$pb.TagNumber(3)
void clearCash() => $_clearField(3);
@$pb.TagNumber(3)
$1.Price ensureCash() => $_ensure(2);
@$pb.TagNumber(4)
$pb.PbList<$0.BacktestPosition> get positions => $_getList(3);
@$pb.TagNumber(5)
$pb.PbList<$0.BacktestTrade> get fills => $_getList(4);
@$pb.TagNumber(6)
$pb.PbList<$0.BacktestEquityPoint> get equityCurve => $_getList(5);
}
const $core.bool _omitFieldNames =
$core.bool.fromEnvironment('protobuf.omit_field_names');
const $core.bool _omitMessageNames =
$core.bool.fromEnvironment('protobuf.omit_message_names');

View file

@ -0,0 +1,11 @@
// This is a generated file - do not edit.
//
// Generated from alt/v1/paper_trading.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports

View file

@ -0,0 +1,169 @@
// This is a generated file - do not edit.
//
// Generated from alt/v1/paper_trading.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, prefer_relative_imports
// ignore_for_file: unused_import
import 'dart:convert' as $convert;
import 'dart:core' as $core;
import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use startPaperTradingRequestDescriptor instead')
const StartPaperTradingRequest$json = {
'1': 'StartPaperTradingRequest',
'2': [
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
{
'1': 'spec',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.BacktestRunSpec',
'10': 'spec'
},
{
'1': 'starting_cash',
'3': 3,
'4': 1,
'5': 11,
'6': '.alt.v1.Price',
'10': 'startingCash'
},
],
};
/// Descriptor for `StartPaperTradingRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List startPaperTradingRequestDescriptor = $convert.base64Decode(
'ChhTdGFydFBhcGVyVHJhZGluZ1JlcXVlc3QSHQoKYWNjb3VudF9pZBgBIAEoCVIJYWNjb3VudE'
'lkEisKBHNwZWMYAiABKAsyFy5hbHQudjEuQmFja3Rlc3RSdW5TcGVjUgRzcGVjEjIKDXN0YXJ0'
'aW5nX2Nhc2gYAyABKAsyDS5hbHQudjEuUHJpY2VSDHN0YXJ0aW5nQ2FzaA==');
@$core.Deprecated('Use startPaperTradingResponseDescriptor instead')
const StartPaperTradingResponse$json = {
'1': 'StartPaperTradingResponse',
'2': [
{
'1': 'state',
'3': 1,
'4': 1,
'5': 11,
'6': '.alt.v1.PaperTradingState',
'10': 'state'
},
{
'1': 'error',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.ErrorInfo',
'10': 'error'
},
],
};
/// Descriptor for `StartPaperTradingResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List startPaperTradingResponseDescriptor = $convert.base64Decode(
'ChlTdGFydFBhcGVyVHJhZGluZ1Jlc3BvbnNlEi8KBXN0YXRlGAEgASgLMhkuYWx0LnYxLlBhcG'
'VyVHJhZGluZ1N0YXRlUgVzdGF0ZRInCgVlcnJvchgCIAEoCzIRLmFsdC52MS5FcnJvckluZm9S'
'BWVycm9y');
@$core.Deprecated('Use getPaperTradingStateRequestDescriptor instead')
const GetPaperTradingStateRequest$json = {
'1': 'GetPaperTradingStateRequest',
'2': [
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
],
};
/// Descriptor for `GetPaperTradingStateRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List getPaperTradingStateRequestDescriptor =
$convert.base64Decode(
'ChtHZXRQYXBlclRyYWRpbmdTdGF0ZVJlcXVlc3QSHQoKYWNjb3VudF9pZBgBIAEoCVIJYWNjb3'
'VudElk');
@$core.Deprecated('Use getPaperTradingStateResponseDescriptor instead')
const GetPaperTradingStateResponse$json = {
'1': 'GetPaperTradingStateResponse',
'2': [
{
'1': 'state',
'3': 1,
'4': 1,
'5': 11,
'6': '.alt.v1.PaperTradingState',
'10': 'state'
},
{
'1': 'error',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.ErrorInfo',
'10': 'error'
},
],
};
/// Descriptor for `GetPaperTradingStateResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List getPaperTradingStateResponseDescriptor =
$convert.base64Decode(
'ChxHZXRQYXBlclRyYWRpbmdTdGF0ZVJlc3BvbnNlEi8KBXN0YXRlGAEgASgLMhkuYWx0LnYxLl'
'BhcGVyVHJhZGluZ1N0YXRlUgVzdGF0ZRInCgVlcnJvchgCIAEoCzIRLmFsdC52MS5FcnJvcklu'
'Zm9SBWVycm9y');
@$core.Deprecated('Use paperTradingStateDescriptor instead')
const PaperTradingState$json = {
'1': 'PaperTradingState',
'2': [
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
{
'1': 'run',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.BacktestRun',
'10': 'run'
},
{'1': 'cash', '3': 3, '4': 1, '5': 11, '6': '.alt.v1.Price', '10': 'cash'},
{
'1': 'positions',
'3': 4,
'4': 3,
'5': 11,
'6': '.alt.v1.BacktestPosition',
'10': 'positions'
},
{
'1': 'fills',
'3': 5,
'4': 3,
'5': 11,
'6': '.alt.v1.BacktestTrade',
'10': 'fills'
},
{
'1': 'equity_curve',
'3': 6,
'4': 3,
'5': 11,
'6': '.alt.v1.BacktestEquityPoint',
'10': 'equityCurve'
},
],
};
/// Descriptor for `PaperTradingState`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List paperTradingStateDescriptor = $convert.base64Decode(
'ChFQYXBlclRyYWRpbmdTdGF0ZRIdCgphY2NvdW50X2lkGAEgASgJUglhY2NvdW50SWQSJQoDcn'
'VuGAIgASgLMhMuYWx0LnYxLkJhY2t0ZXN0UnVuUgNydW4SIQoEY2FzaBgDIAEoCzINLmFsdC52'
'MS5QcmljZVIEY2FzaBI2Cglwb3NpdGlvbnMYBCADKAsyGC5hbHQudjEuQmFja3Rlc3RQb3NpdG'
'lvblIJcG9zaXRpb25zEisKBWZpbGxzGAUgAygLMhUuYWx0LnYxLkJhY2t0ZXN0VHJhZGVSBWZp'
'bGxzEj4KDGVxdWl0eV9jdXJ2ZRgGIAMoCzIbLmFsdC52MS5CYWNrdGVzdEVxdWl0eVBvaW50Ug'
'tlcXVpdHlDdXJ2ZQ==');

View file

@ -3,6 +3,7 @@ import 'package:alt_client/src/contracts/alt_contracts.dart';
import 'package:alt_client/src/generated/alt/v1/common.pb.dart';
import 'package:alt_client/src/generated/alt/v1/market.pb.dart';
import 'package:alt_client/src/generated/alt/v1/backtest.pb.dart';
import 'package:alt_client/src/generated/alt/v1/paper_trading.pb.dart';
void main() {
group('ALT contracts parser map helper tests', () {
@ -29,9 +30,14 @@ void main() {
GetBacktestRunDetailResponse(),
CompareBacktestRunsRequest(),
CompareBacktestRunsResponse(),
StartPaperTradingRequest(),
StartPaperTradingResponse(),
GetPaperTradingStateRequest(),
GetPaperTradingStateResponse(),
PaperTradingState(),
];
expect(parsers.length, equals(19));
expect(parsers.length, equals(24));
for (final msg in expectedMessages) {
final qualifiedName = msg.info_.qualifiedMessageName;

View file

@ -26,6 +26,7 @@ protos=(
"alt/v1/common.proto"
"alt/v1/market.proto"
"alt/v1/backtest.proto"
"alt/v1/paper_trading.proto"
)
mkdir -p "$go_out"

View file

@ -0,0 +1,415 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.3
// source: alt/v1/paper_trading.proto
package altv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type StartPaperTradingRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
Spec *BacktestRunSpec `protobuf:"bytes,2,opt,name=spec,proto3" json:"spec,omitempty"`
StartingCash *Price `protobuf:"bytes,3,opt,name=starting_cash,json=startingCash,proto3" json:"starting_cash,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartPaperTradingRequest) Reset() {
*x = StartPaperTradingRequest{}
mi := &file_alt_v1_paper_trading_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartPaperTradingRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartPaperTradingRequest) ProtoMessage() {}
func (x *StartPaperTradingRequest) ProtoReflect() protoreflect.Message {
mi := &file_alt_v1_paper_trading_proto_msgTypes[0]
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 StartPaperTradingRequest.ProtoReflect.Descriptor instead.
func (*StartPaperTradingRequest) Descriptor() ([]byte, []int) {
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{0}
}
func (x *StartPaperTradingRequest) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
func (x *StartPaperTradingRequest) GetSpec() *BacktestRunSpec {
if x != nil {
return x.Spec
}
return nil
}
func (x *StartPaperTradingRequest) GetStartingCash() *Price {
if x != nil {
return x.StartingCash
}
return nil
}
type StartPaperTradingResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
State *PaperTradingState `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"`
Error *ErrorInfo `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartPaperTradingResponse) Reset() {
*x = StartPaperTradingResponse{}
mi := &file_alt_v1_paper_trading_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartPaperTradingResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartPaperTradingResponse) ProtoMessage() {}
func (x *StartPaperTradingResponse) ProtoReflect() protoreflect.Message {
mi := &file_alt_v1_paper_trading_proto_msgTypes[1]
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 StartPaperTradingResponse.ProtoReflect.Descriptor instead.
func (*StartPaperTradingResponse) Descriptor() ([]byte, []int) {
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{1}
}
func (x *StartPaperTradingResponse) GetState() *PaperTradingState {
if x != nil {
return x.State
}
return nil
}
func (x *StartPaperTradingResponse) GetError() *ErrorInfo {
if x != nil {
return x.Error
}
return nil
}
type GetPaperTradingStateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountId string `protobuf:"bytes,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetPaperTradingStateRequest) Reset() {
*x = GetPaperTradingStateRequest{}
mi := &file_alt_v1_paper_trading_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetPaperTradingStateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetPaperTradingStateRequest) ProtoMessage() {}
func (x *GetPaperTradingStateRequest) ProtoReflect() protoreflect.Message {
mi := &file_alt_v1_paper_trading_proto_msgTypes[2]
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 GetPaperTradingStateRequest.ProtoReflect.Descriptor instead.
func (*GetPaperTradingStateRequest) Descriptor() ([]byte, []int) {
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{2}
}
func (x *GetPaperTradingStateRequest) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
type GetPaperTradingStateResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
State *PaperTradingState `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"`
Error *ErrorInfo `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetPaperTradingStateResponse) Reset() {
*x = GetPaperTradingStateResponse{}
mi := &file_alt_v1_paper_trading_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetPaperTradingStateResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetPaperTradingStateResponse) ProtoMessage() {}
func (x *GetPaperTradingStateResponse) ProtoReflect() protoreflect.Message {
mi := &file_alt_v1_paper_trading_proto_msgTypes[3]
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 GetPaperTradingStateResponse.ProtoReflect.Descriptor instead.
func (*GetPaperTradingStateResponse) Descriptor() ([]byte, []int) {
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{3}
}
func (x *GetPaperTradingStateResponse) GetState() *PaperTradingState {
if x != nil {
return x.State
}
return nil
}
func (x *GetPaperTradingStateResponse) GetError() *ErrorInfo {
if x != nil {
return x.Error
}
return nil
}
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
}
func (x *PaperTradingState) Reset() {
*x = PaperTradingState{}
mi := &file_alt_v1_paper_trading_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PaperTradingState) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PaperTradingState) ProtoMessage() {}
func (x *PaperTradingState) ProtoReflect() protoreflect.Message {
mi := &file_alt_v1_paper_trading_proto_msgTypes[4]
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 PaperTradingState.ProtoReflect.Descriptor instead.
func (*PaperTradingState) Descriptor() ([]byte, []int) {
return file_alt_v1_paper_trading_proto_rawDescGZIP(), []int{4}
}
func (x *PaperTradingState) GetAccountId() string {
if x != nil {
return x.AccountId
}
return ""
}
func (x *PaperTradingState) GetRun() *BacktestRun {
if x != nil {
return x.Run
}
return nil
}
func (x *PaperTradingState) GetCash() *Price {
if x != nil {
return x.Cash
}
return nil
}
func (x *PaperTradingState) GetPositions() []*BacktestPosition {
if x != nil {
return x.Positions
}
return nil
}
func (x *PaperTradingState) GetFills() []*BacktestTrade {
if x != nil {
return x.Fills
}
return nil
}
func (x *PaperTradingState) GetEquityCurve() []*BacktestEquityPoint {
if x != nil {
return x.EquityCurve
}
return nil
}
var File_alt_v1_paper_trading_proto protoreflect.FileDescriptor
const file_alt_v1_paper_trading_proto_rawDesc = "" +
"\n" +
"\x1aalt/v1/paper_trading.proto\x12\x06alt.v1\x1a\x13alt/v1/common.proto\x1a\x13alt/v1/market.proto\x1a\x15alt/v1/backtest.proto\"\x9a\x01\n" +
"\x18StartPaperTradingRequest\x12\x1d\n" +
"\n" +
"account_id\x18\x01 \x01(\tR\taccountId\x12+\n" +
"\x04spec\x18\x02 \x01(\v2\x17.alt.v1.BacktestRunSpecR\x04spec\x122\n" +
"\rstarting_cash\x18\x03 \x01(\v2\r.alt.v1.PriceR\fstartingCash\"u\n" +
"\x19StartPaperTradingResponse\x12/\n" +
"\x05state\x18\x01 \x01(\v2\x19.alt.v1.PaperTradingStateR\x05state\x12'\n" +
"\x05error\x18\x02 \x01(\v2\x11.alt.v1.ErrorInfoR\x05error\"<\n" +
"\x1bGetPaperTradingStateRequest\x12\x1d\n" +
"\n" +
"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" +
"\x11PaperTradingState\x12\x1d\n" +
"\n" +
"account_id\x18\x01 \x01(\tR\taccountId\x12%\n" +
"\x03run\x18\x02 \x01(\v2\x13.alt.v1.BacktestRunR\x03run\x12!\n" +
"\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"
var (
file_alt_v1_paper_trading_proto_rawDescOnce sync.Once
file_alt_v1_paper_trading_proto_rawDescData []byte
)
func file_alt_v1_paper_trading_proto_rawDescGZIP() []byte {
file_alt_v1_paper_trading_proto_rawDescOnce.Do(func() {
file_alt_v1_paper_trading_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_alt_v1_paper_trading_proto_rawDesc), len(file_alt_v1_paper_trading_proto_rawDesc)))
})
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_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
}
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
4, // 2: alt.v1.StartPaperTradingResponse.state:type_name -> alt.v1.PaperTradingState
7, // 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
}
func init() { file_alt_v1_paper_trading_proto_init() }
func file_alt_v1_paper_trading_proto_init() {
if File_alt_v1_paper_trading_proto != nil {
return
}
file_alt_v1_common_proto_init()
file_alt_v1_market_proto_init()
file_alt_v1_backtest_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
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,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_alt_v1_paper_trading_proto_goTypes,
DependencyIndexes: file_alt_v1_paper_trading_proto_depIdxs,
MessageInfos: file_alt_v1_paper_trading_proto_msgTypes,
}.Build()
File_alt_v1_paper_trading_proto = out.File
file_alt_v1_paper_trading_proto_goTypes = nil
file_alt_v1_paper_trading_proto_depIdxs = nil
}

View file

@ -0,0 +1,43 @@
syntax = "proto3";
package alt.v1;
option go_package = "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1;altv1";
import "alt/v1/common.proto";
import "alt/v1/market.proto";
import "alt/v1/backtest.proto";
// paper_trading.proto carries the operator-facing paper trading command/query
// surface. It is kept separate from backtest.proto so a paper account state can
// be started and inspected distinctly from backtest analysis while still reusing
// the shared backtest run/trade/position/equity payload shapes.
message StartPaperTradingRequest {
string account_id = 1;
BacktestRunSpec spec = 2;
Price starting_cash = 3;
}
message StartPaperTradingResponse {
PaperTradingState state = 1;
ErrorInfo error = 2;
}
message GetPaperTradingStateRequest {
string account_id = 1;
}
message GetPaperTradingStateResponse {
PaperTradingState state = 1;
ErrorInfo error = 2;
}
message PaperTradingState {
string account_id = 1;
BacktestRun run = 2;
Price cash = 3;
repeated BacktestPosition positions = 4;
repeated BacktestTrade fills = 5;
repeated BacktestEquityPoint equity_curve = 6;
}

View file

@ -37,6 +37,12 @@ func messageFactories() []func() proto.Message {
func() proto.Message { return &altv1.GetBacktestRunDetailResponse{} },
func() proto.Message { return &altv1.CompareBacktestRunsRequest{} },
func() proto.Message { return &altv1.CompareBacktestRunsResponse{} },
// paper trading surface: start / state
func() proto.Message { return &altv1.StartPaperTradingRequest{} },
func() proto.Message { return &altv1.StartPaperTradingResponse{} },
func() proto.Message { return &altv1.GetPaperTradingStateRequest{} },
func() proto.Message { return &altv1.GetPaperTradingStateResponse{} },
func() proto.Message { return &altv1.PaperTradingState{} },
}
}

View file

@ -39,6 +39,12 @@ func requiredAPIMessages() []proto.Message {
&altv1.GetBacktestRunDetailResponse{},
&altv1.CompareBacktestRunsRequest{},
&altv1.CompareBacktestRunsResponse{},
// paper trading start / state
&altv1.StartPaperTradingRequest{},
&altv1.StartPaperTradingResponse{},
&altv1.GetPaperTradingStateRequest{},
&altv1.GetPaperTradingStateResponse{},
&altv1.PaperTradingState{},
}
}

View file

@ -24,6 +24,8 @@ type fakeWorkerClient struct {
instReq *altv1.ListInstrumentsRequest
barsReq *altv1.ListBarsRequest
importReq *altv1.ImportDailyBarsRequest
paperStartReq *altv1.StartPaperTradingRequest
paperStateReq *altv1.GetPaperTradingStateRequest
startRes *altv1.StartBacktestResponse
getRunRes *altv1.GetBacktestRunResponse
@ -34,6 +36,8 @@ type fakeWorkerClient struct {
instRes *altv1.ListInstrumentsResponse
barsRes *altv1.ListBarsResponse
importRes *altv1.ImportDailyBarsResponse
paperStartRes *altv1.StartPaperTradingResponse
paperStateRes *altv1.GetPaperTradingStateResponse
err error
connectErr error
@ -81,6 +85,14 @@ func (f *fakeWorkerClient) CompareBacktestRuns(ctx context.Context, req *altv1.C
f.compareReq = req
return f.compareRes, f.err
}
func (f *fakeWorkerClient) StartPaperTrading(ctx context.Context, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
f.paperStartReq = req
return f.paperStartRes, f.err
}
func (f *fakeWorkerClient) GetPaperTradingState(ctx context.Context, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
f.paperStateReq = req
return f.paperStateRes, f.err
}
func (f *fakeWorkerClient) ListInstruments(ctx context.Context, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) {
f.instReq = req
return f.instRes, f.err

View file

@ -29,6 +29,7 @@ func sessionHandlers(worker workerclient.WorkerClient) []sessionHandler {
}
handlers = append(handlers, marketHandlers(worker)...)
handlers = append(handlers, backtestHandlers(worker)...)
handlers = append(handlers, paperHandlers(worker)...)
return handlers
}
@ -77,7 +78,7 @@ func handleHello(worker workerclient.WorkerClient, req *altv1.HelloRequest) (*al
}
func capabilitiesForSession(worker workerclient.WorkerClient) []string {
caps := []string{"hello", "request-response", "market-read", "market-import", "backtest-read", "backtest-start", "worker-execution"}
caps := []string{"hello", "request-response", "market-read", "market-import", "backtest-read", "backtest-start", "worker-execution", "paper-trading"}
if worker != nil && worker.IsConnected() {
caps = append(caps, "worker-available")
} else {

View file

@ -0,0 +1,84 @@
package socket
import (
"context"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
"git.toki-labs.com/toki/alt/services/api/internal/workerclient"
)
// paperHandlers returns the API session handlers for the paper trading surface.
// Each handler validates the request shape and forwards it to the worker through
// the injected Worker client; paper execution and state stay worker-owned.
func paperHandlers(worker workerclient.WorkerClient) []sessionHandler {
return []sessionHandler{
{
requestType: protoSocket.TypeNameOf(&altv1.StartPaperTradingRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](&client.Communicator, func(req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
return handleStartPaperTrading(worker, req)
})
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetPaperTradingStateRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](&client.Communicator, func(req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
return handleGetPaperTradingState(worker, req)
})
},
},
}
}
func handleStartPaperTrading(worker workerclient.WorkerClient, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
if req.GetAccountId() == "" {
return &altv1.StartPaperTradingResponse{Error: invalidRequest("account_id is required")}, nil
}
if req.GetSpec() == nil {
return &altv1.StartPaperTradingResponse{Error: invalidRequest("spec is required")}, nil
}
if req.GetStartingCash() == nil {
return &altv1.StartPaperTradingResponse{Error: invalidRequest("starting_cash is required")}, nil
}
if worker == nil {
return &altv1.StartPaperTradingResponse{Error: workerUnavailable()}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
defer cancel()
if err := worker.Connect(ctx); err != nil {
return &altv1.StartPaperTradingResponse{Error: workerErrorInfo(err)}, nil
}
res, err := worker.StartPaperTrading(ctx, req)
if err != nil {
return &altv1.StartPaperTradingResponse{Error: workerErrorInfo(err)}, nil
}
if res == nil {
return &altv1.StartPaperTradingResponse{Error: internalError("worker returned no paper start response")}, nil
}
return res, nil
}
func handleGetPaperTradingState(worker workerclient.WorkerClient, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetPaperTradingStateResponse{Error: invalidRequest("account_id is required")}, nil
}
if worker == nil {
return &altv1.GetPaperTradingStateResponse{Error: workerUnavailable()}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
defer cancel()
if err := worker.Connect(ctx); err != nil {
return &altv1.GetPaperTradingStateResponse{Error: workerErrorInfo(err)}, nil
}
res, err := worker.GetPaperTradingState(ctx, req)
if err != nil {
return &altv1.GetPaperTradingStateResponse{Error: workerErrorInfo(err)}, nil
}
if res == nil {
return &altv1.GetPaperTradingStateResponse{Error: internalError("worker returned no paper state response")}, nil
}
return res, nil
}

View file

@ -0,0 +1,193 @@
package socket
import (
"testing"
"time"
altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1"
"git.toki-labs.com/toki/alt/services/api/internal/workerclient"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
)
func validStartPaper() *altv1.StartPaperTradingRequest {
return &altv1.StartPaperTradingRequest{
AccountId: "paper-1",
Spec: &altv1.BacktestRunSpec{StrategyId: "strategy-v1"},
StartingCash: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "10000000"}},
}
}
func TestHandleStartPaperTradingForwards(t *testing.T) {
fake := &fakeWorkerClient{paperStartRes: &altv1.StartPaperTradingResponse{
State: &altv1.PaperTradingState{AccountId: "paper-1"},
}}
resp, err := handleStartPaperTrading(fake, validStartPaper())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fake.paperStartReq == nil {
t.Fatal("expected request to be forwarded to worker")
}
if resp.GetState().GetAccountId() != "paper-1" {
t.Errorf("unexpected account id: %q", resp.GetState().GetAccountId())
}
}
func TestHandleStartPaperTradingValidation(t *testing.T) {
fake := &fakeWorkerClient{}
cases := []*altv1.StartPaperTradingRequest{
{Spec: &altv1.BacktestRunSpec{StrategyId: "x"}, StartingCash: &altv1.Price{}}, // missing account_id
{AccountId: "paper-1", StartingCash: &altv1.Price{}}, // missing spec
{AccountId: "paper-1", Spec: &altv1.BacktestRunSpec{StrategyId: "x"}}, // missing starting_cash
}
for i, req := range cases {
resp, err := handleStartPaperTrading(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.paperStartReq != nil {
t.Error("worker must not be called when validation fails")
}
}
func TestHandleStartPaperTradingNilWorker(t *testing.T) {
resp, err := handleStartPaperTrading(nil, validStartPaper())
if err != nil {
t.Fatalf("expected typed unavailable response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
}
func TestHandleStartPaperTradingConnectFailure(t *testing.T) {
fake := &fakeWorkerClient{connectErr: workerclient.ErrUnavailable}
resp, err := handleStartPaperTrading(fake, validStartPaper())
if err != nil {
t.Fatalf("expected typed unavailable response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
if fake.paperStartReq != nil {
t.Error("request must not be forwarded on connect failure")
}
}
func TestHandleStartPaperTradingMapsTimeout(t *testing.T) {
fake := &fakeWorkerClient{err: workerclient.ErrTimeout}
resp, err := handleStartPaperTrading(fake, validStartPaper())
if err != nil {
t.Fatalf("expected typed timeout response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorTimeout)
}
func TestHandleStartPaperTradingNilWorkerResponse(t *testing.T) {
fake := &fakeWorkerClient{}
resp, err := handleStartPaperTrading(fake, validStartPaper())
if err != nil {
t.Fatalf("expected typed internal response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInternal)
if fake.paperStartReq == nil {
t.Error("expected worker to be called before nil response was detected")
}
}
func TestHandleGetPaperTradingStateForwards(t *testing.T) {
fake := &fakeWorkerClient{paperStateRes: &altv1.GetPaperTradingStateResponse{
State: &altv1.PaperTradingState{AccountId: "paper-1"},
}}
resp, err := handleGetPaperTradingState(fake, &altv1.GetPaperTradingStateRequest{AccountId: "paper-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fake.paperStateReq.GetAccountId() != "paper-1" {
t.Errorf("account id not forwarded unchanged, got %q", fake.paperStateReq.GetAccountId())
}
if resp.GetState().GetAccountId() != "paper-1" {
t.Errorf("unexpected account id: %q", resp.GetState().GetAccountId())
}
}
func TestHandleGetPaperTradingStateRequiresAccountID(t *testing.T) {
fake := &fakeWorkerClient{}
resp, err := handleGetPaperTradingState(fake, &altv1.GetPaperTradingStateRequest{})
if err != nil {
t.Fatalf("expected typed validation response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
if fake.paperStateReq != nil {
t.Error("worker must not be called when validation fails")
}
}
func TestHandleGetPaperTradingStateNilWorkerResponse(t *testing.T) {
fake := &fakeWorkerClient{}
resp, err := handleGetPaperTradingState(fake, &altv1.GetPaperTradingStateRequest{AccountId: "paper-1"})
if err != nil {
t.Fatalf("expected typed internal response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInternal)
if fake.paperStateReq == nil {
t.Error("expected request to be forwarded before nil response was detected")
}
}
func TestPaperHandlersRegisteredInSession(t *testing.T) {
registered := make(map[string]bool)
for _, h := range sessionHandlers(nil) {
registered[h.requestType] = true
}
for _, req := range []string{
protoSocket.TypeNameOf(&altv1.StartPaperTradingRequest{}),
protoSocket.TypeNameOf(&altv1.GetPaperTradingStateRequest{}),
} {
if !registered[req] {
t.Errorf("missing API handler registration for %q", req)
}
}
}
func TestPaperSocketWorkerUnavailableReturnsTypedError(t *testing.T) {
fake := &fakeWorkerClient{err: workerclient.ErrUnavailable}
client := startBacktestAPITestClient(t, fake)
resp, err := protoSocket.SendRequestTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](
&client.Communicator,
validStartPaper(),
500*time.Millisecond,
)
if err != nil {
t.Fatalf("request should return typed error response without timeout: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
if fake.paperStartReq == nil {
t.Fatal("valid start request should be forwarded before worker error is mapped")
}
}
func TestPaperSocketValidationReturnsTypedError(t *testing.T) {
fake := &fakeWorkerClient{}
client := startBacktestAPITestClient(t, fake)
resp, err := protoSocket.SendRequestTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](
&client.Communicator,
&altv1.GetPaperTradingStateRequest{},
500*time.Millisecond,
)
if err != nil {
t.Fatalf("request should return typed error response without timeout: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
if fake.paperStateReq != nil {
t.Fatal("worker must not be called for socket-level validation failures")
}
}

View file

@ -69,6 +69,7 @@ func TestServerRespondsToHelloRequest(t *testing.T) {
"backtest-read": true,
"backtest-start": true,
"worker-execution": true,
"paper-trading": true,
"worker-unavailable": true,
}
for _, cap := range res.GetCapabilities() {
@ -128,6 +129,7 @@ func TestServerRespondsToHelloRequest_WorkerAvailable(t *testing.T) {
"backtest-read": true,
"backtest-start": true,
"worker-execution": true,
"paper-trading": true,
"worker-available": true,
}
for _, cap := range res.GetCapabilities() {
@ -341,4 +343,11 @@ func TestCapabilitiesAndHandlersSync(t *testing.T) {
t.Error("worker-execution capability missing although StartBacktest handler is registered")
}
}
// 4. Check paper trading start/state -> paper-trading
hasPaperRequest := registered[protoSocket.TypeNameOf(&altv1.StartPaperTradingRequest{})] &&
registered[protoSocket.TypeNameOf(&altv1.GetPaperTradingStateRequest{})]
if hasPaperRequest && !capSet["paper-trading"] {
t.Error("paper-trading capability missing although paper trading handlers are registered")
}
}

View file

@ -37,6 +37,11 @@ type WorkerClient interface {
GetBacktestResult(ctx context.Context, req *altv1.GetBacktestResultRequest) (*altv1.GetBacktestResultResponse, error)
CompareBacktestRuns(ctx context.Context, req *altv1.CompareBacktestRunsRequest) (*altv1.CompareBacktestRunsResponse, error)
// Paper trading command/query surface. The API forwards client requests onto
// the worker unchanged; paper execution and state stay worker-owned.
StartPaperTrading(ctx context.Context, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error)
GetPaperTradingState(ctx context.Context, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, 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)
@ -153,6 +158,14 @@ func (c *socketClient) CompareBacktestRuns(ctx context.Context, req *altv1.Compa
return sendTyped[*altv1.CompareBacktestRunsRequest, *altv1.CompareBacktestRunsResponse](c, ctx, req)
}
func (c *socketClient) StartPaperTrading(ctx context.Context, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
return sendTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](c, ctx, req)
}
func (c *socketClient) GetPaperTradingState(ctx context.Context, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
return sendTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](c, ctx, req)
}
func (c *socketClient) ListInstruments(ctx context.Context, req *altv1.ListInstrumentsRequest) (*altv1.ListInstrumentsResponse, error) {
return sendTyped[*altv1.ListInstrumentsRequest, *altv1.ListInstrumentsResponse](c, ctx, req)
}

View file

@ -122,6 +122,68 @@ func TestWorkerClient_StartBacktest_Success(t *testing.T) {
}
}
func TestWorkerClient_StartPaperTrading_Success(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](&client.Communicator, func(req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
return &altv1.StartPaperTradingResponse{
State: &altv1.PaperTradingState{
AccountId: req.GetAccountId(),
Cash: req.GetStartingCash(),
},
}, nil
})
})
defer cleanup()
client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port))
ctx := context.Background()
if err := client.Connect(ctx); err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer client.Close()
res, err := client.StartPaperTrading(ctx, &altv1.StartPaperTradingRequest{
AccountId: "paper-1",
Spec: &altv1.BacktestRunSpec{StrategyId: "strategy-v1"},
StartingCash: &altv1.Price{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "10000000"}},
})
if err != nil {
t.Fatalf("StartPaperTrading failed: %v", err)
}
if res.GetState().GetAccountId() != "paper-1" {
t.Errorf("account id did not round-trip, got %q", res.GetState().GetAccountId())
}
if res.GetState().GetCash().GetAmount().GetValue() != "10000000" {
t.Errorf("starting cash did not round-trip, got %q", res.GetState().GetCash().GetAmount().GetValue())
}
}
func TestWorkerClient_GetPaperTradingState_Success(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](&client.Communicator, func(req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
return &altv1.GetPaperTradingStateResponse{
State: &altv1.PaperTradingState{AccountId: req.GetAccountId()},
}, nil
})
})
defer cleanup()
client := New(fmt.Sprintf("ws://127.0.0.1:%d/socket", port))
ctx := context.Background()
if err := client.Connect(ctx); err != nil {
t.Fatalf("failed to connect: %v", err)
}
defer client.Close()
res, err := client.GetPaperTradingState(ctx, &altv1.GetPaperTradingStateRequest{AccountId: "paper-1"})
if err != nil {
t.Fatalf("GetPaperTradingState failed: %v", err)
}
if res.GetState().GetAccountId() != "paper-1" {
t.Errorf("account id did not round-trip, got %q", res.GetState().GetAccountId())
}
}
func TestWorkerClient_GetBacktestRun_Success(t *testing.T) {
port, cleanup := startFakeWorker(t, func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetBacktestRunRequest, *altv1.GetBacktestRunResponse](&client.Communicator, func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error) {

View file

@ -17,6 +17,7 @@ import (
"git.toki-labs.com/toki/alt/services/worker/internal/config"
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
"git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer"
"git.toki-labs.com/toki/alt/services/worker/internal/papertrading"
"git.toki-labs.com/toki/alt/services/worker/internal/providers/kis"
"git.toki-labs.com/toki/alt/services/worker/internal/socket"
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres"
@ -87,6 +88,12 @@ func storeBackedDeps(runner *jobs.Runner, store *postgres.Store) socket.Deps {
jobs.RegisterRunBacktestHandler(runner, store, engine, time.Now)
starter := jobs.NewBacktestStarter(runner, store)
// Paper trading reuses the same imported-bar source and bundled strategy
// resolver as backtest, but runs synchronously into a process-local
// in-memory state keyed by account id (no durable store for readiness).
paperEngine := papertrading.NewEngine(barSource, strategyPort)
paperService := papertrading.NewService(paperEngine, time.Now)
return socket.Deps{
Starter: starter,
Analysis: store,
@ -94,5 +101,6 @@ func storeBackedDeps(runner *jobs.Runner, store *postgres.Store) socket.Deps {
Instruments: store,
Bars: store,
DailyBarImporter: kisImporter,
Paper: paperService,
}
}

View file

@ -28,6 +28,9 @@ func TestStoreBackedDepsWiresBacktestSurfaces(t *testing.T) {
if deps.DailyBarImporter == nil {
t.Error("expected daily bar importer to be wired")
}
if deps.Paper == nil {
t.Error("expected paper trading service to be wired")
}
// Wiring registers the import and run-backtest job handlers on the runner, so
// it must hold at least those two handlers after wiring.

View file

@ -33,6 +33,12 @@ func messageFactories() []func() proto.Message {
func() proto.Message { return &altv1.GetBacktestRunDetailResponse{} },
func() proto.Message { return &altv1.CompareBacktestRunsRequest{} },
func() proto.Message { return &altv1.CompareBacktestRunsResponse{} },
// paper trading surface: start / state
func() proto.Message { return &altv1.StartPaperTradingRequest{} },
func() proto.Message { return &altv1.StartPaperTradingResponse{} },
func() proto.Message { return &altv1.GetPaperTradingStateRequest{} },
func() proto.Message { return &altv1.GetPaperTradingStateResponse{} },
func() proto.Message { return &altv1.PaperTradingState{} },
}
}

View file

@ -35,6 +35,12 @@ func requiredWorkerMessages() []proto.Message {
&altv1.GetBacktestRunDetailResponse{},
&altv1.CompareBacktestRunsRequest{},
&altv1.CompareBacktestRunsResponse{},
// paper trading start / state
&altv1.StartPaperTradingRequest{},
&altv1.StartPaperTradingResponse{},
&altv1.GetPaperTradingStateRequest{},
&altv1.GetPaperTradingStateResponse{},
&altv1.PaperTradingState{},
}
}

View file

@ -0,0 +1,228 @@
package papertrading
import (
"context"
"fmt"
"sort"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
// BarSource resolves daily bars for a paper trading run.
type BarSource interface {
GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error)
}
// StrategyPort resolves strategies for a paper trading run.
type StrategyPort interface {
GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error)
}
// Engine implements the daily paper execution loop. Orders decided in one bar
// fill on the next bar: market orders at next-bar open, limit orders when the
// bar's high/low crosses the limit price.
type Engine struct {
barSource BarSource
strategyPort StrategyPort
}
// NewEngine creates a new paper trading Engine instance.
func NewEngine(barSource BarSource, strategyPort StrategyPort) *Engine {
return &Engine{
barSource: barSource,
strategyPort: strategyPort,
}
}
// RunRequest carries all inputs for one paper execution run.
type RunRequest struct {
Account backtest.PaperAccount
Run backtest.Run
}
// RejectedOrder records an order that was denied by a risk gate or could not
// be applied to the portfolio.
type RejectedOrder struct {
Order backtest.OrderIntent
Reason string
BarTime time.Time
Instrument market.InstrumentID
CashBefore backtest.PaperAccount
}
// Snapshot captures the terminal state of a paper run.
type Snapshot struct {
Account backtest.PaperAccount
Fills []backtest.Fill
Rejected []RejectedOrder
EquityCurve []backtest.EquityPoint
}
// Run executes a daily paper trading simulation and returns the final snapshot.
func (e *Engine) Run(ctx context.Context, req RunRequest) (*Snapshot, error) {
strategy, err := e.strategyPort.GetStrategy(ctx, req.Run.Spec.StrategyID)
if err != nil {
return nil, fmt.Errorf("failed to get strategy %s: %w", req.Run.Spec.StrategyID, err)
}
bars, err := e.barSource.GetBars(ctx, req.Run.Spec.Market, req.Run.Spec.Timeframe, req.Run.Spec.From, req.Run.Spec.To)
if err != nil {
return nil, fmt.Errorf("failed to get bars: %w", err)
}
sort.Slice(bars, func(i, j int) bool {
if bars[i].Timestamp.Equal(bars[j].Timestamp) {
return bars[i].InstrumentID < bars[j].InstrumentID
}
return bars[i].Timestamp.Before(bars[j].Timestamp)
})
if len(bars) == 0 {
return &Snapshot{
Account: req.Account,
Fills: nil,
Rejected: nil,
EquityCurve: nil,
}, nil
}
// Clone starting account state so we don't mutate the input
positionsCopy := make(map[market.InstrumentID]backtest.Position, len(req.Account.Portfolio.Positions))
for k, v := range req.Account.Portfolio.Positions {
positionsCopy[k] = v
}
account := req.Account
account.Portfolio = backtest.PortfolioState{
Cash: req.Account.Portfolio.Cash,
Positions: positionsCopy,
}
var equityCurve []backtest.EquityPoint
var fills []backtest.Fill
var rejected []RejectedOrder
// Instrument-scoped pending: an order is only eligible to fill on a later bar
// of the *same* instrument. This prevents orders for instrument A from
// accidentally matching on the same timestamp of instrument B when
// StorageBarSource returns multi-instrument bars sorted by time.
pendingOrders := make(map[market.InstrumentID][]backtest.OrderIntent)
for _, bar := range bars {
// --- Phase 1: Fill pending orders for this instrument ---
instrumentPending, hasPending := pendingOrders[bar.InstrumentID]
if hasPending {
delete(pendingOrders, bar.InstrumentID)
}
var newFills []backtest.Fill
var newRejected []RejectedOrder
var nextForThisInst []backtest.OrderIntent
for _, order := range instrumentPending {
decision := backtest.CheckRisk(account, order)
if !decision.Allowed {
newRejected = append(newRejected, RejectedOrder{
Order: order,
Reason: decision.Reason,
BarTime: bar.Timestamp,
Instrument: order.InstrumentID,
CashBefore: account,
})
continue
}
fill, ok, err := backtest.FillOrderOnDailyBar(order, bar)
if err != nil {
return nil, fmt.Errorf("fill order on bar at %s: %w", bar.Timestamp, err)
}
if !ok {
// Limit not crossed, carry forward for this instrument
nextForThisInst = append(nextForThisInst, order)
continue
}
nextPortfolio, err := account.Portfolio.ApplyFill(fill)
if err != nil {
// ApplyFill failed (e.g. insufficient cash for buy, insufficient
// position for sell). Record as rejected and discard from pending
// to avoid infinite retry across bars.
newRejected = append(newRejected, RejectedOrder{
Order: order,
Reason: err.Error(),
BarTime: bar.Timestamp,
Instrument: order.InstrumentID,
CashBefore: account,
})
continue
}
account.Portfolio = nextPortfolio
newFills = append(newFills, fill)
}
rejected = append(rejected, newRejected...)
// Only merge remaining pending orders for this instrument back if any
if len(nextForThisInst) > 0 {
pendingOrders[bar.InstrumentID] = nextForThisInst
}
fills = append(fills, newFills...)
// --- Phase 2: Run strategy with current account state (after fills) ---
input := backtest.StrategyInput{
Run: req.Run,
Bar: bar,
Portfolio: account.Portfolio,
History: nil,
}
strategyOrders, err := strategy.Decide(input)
if err != nil {
return nil, fmt.Errorf("strategy decide failed at %s: %w", bar.Timestamp, err)
}
// Store strategy decisions in instrument-scoped pending keyed by each
// order's own InstrumentID so that a strategy can return orders for an
// instrument different from the current bar. Empty InstrumentID is treated
// as a strategy bug: the order is rejected immediately with no retry so the
// fault is visible early.
for _, order := range strategyOrders {
if order.InstrumentID == "" {
rejected = append(rejected, RejectedOrder{
Order: order,
Reason: "empty order.InstrumentID",
BarTime: bar.Timestamp,
Instrument: bar.InstrumentID,
CashBefore: account,
})
continue
}
pendingOrders[order.InstrumentID] = append(pendingOrders[order.InstrumentID], order)
}
// --- Phase 3: Mark current position price and record equity ---
if _, ok := account.Portfolio.Position(bar.InstrumentID); ok {
account.Portfolio, err = account.Portfolio.MarkPrice(bar.InstrumentID, bar.Close)
if err != nil {
return nil, fmt.Errorf("failed to mark price for %s: %w", bar.InstrumentID, err)
}
}
equity, err := account.Portfolio.Equity()
if err != nil {
return nil, fmt.Errorf("failed to calculate equity at %s: %w", bar.Timestamp, err)
}
equityCurve = append(equityCurve, backtest.EquityPoint{
Timestamp: bar.Timestamp,
Equity: equity,
})
account.UpdatedAt = bar.Timestamp
}
return &Snapshot{
Account: account, // req.Account.ID is now preserved
Fills: fills,
Rejected: rejected,
EquityCurve: equityCurve,
}, nil
}

View file

@ -0,0 +1,821 @@
package papertrading
import (
"context"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
// --- helpers ---
type testBarSource struct {
bars []market.Bar
}
func (t *testBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
return t.bars, nil
}
type testStrategyPort struct {
strategy backtest.Strategy
}
func (t *testStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) {
return t.strategy, nil
}
// simpleBuyStrategy buys 1 unit on the first bar only.
type simpleBuyStrategy struct {
id backtest.StrategyID
}
func (s simpleBuyStrategy) ID() backtest.StrategyID { return s.id }
func (s simpleBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
// buyThenSellStrategy buys on bar 1, sells 1 unit on bar 2.
type buyThenSellStrategy struct {
id backtest.StrategyID
}
func (s buyThenSellStrategy) ID() backtest.StrategyID { return s.id }
func (s buyThenSellStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
if day == 2 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideSell,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
// emptyInstOrderStrategy returns an order with empty InstrumentID to verify rejection policy.
type emptyInstOrderStrategy struct {
id backtest.StrategyID
}
func (s emptyInstOrderStrategy) ID() backtest.StrategyID { return s.id }
func (s emptyInstOrderStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: "", // deliberately empty
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
// limitBuyStrategy: on day 1 places a limit buy at price 1050, then buys more on day 3.
type limitBuyStrategy struct {
id backtest.StrategyID
}
func (s limitBuyStrategy) ID() backtest.StrategyID { return s.id }
func (s limitBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeLimit,
LimitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1050"}},
}}, nil
}
if day == 3 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
// nonCrossedLimitOnSellStrategy buys 1 unit on day 1, then places a limit sell at 2000 on day 2.
// Bar highs: day1=1100, day2=1200, day3=1300. None of them cross 2000, so the order persists pending.
type nonCrossedLimitOnSellStrategy struct {
id backtest.StrategyID
}
func (s nonCrossedLimitOnSellStrategy) ID() backtest.StrategyID { return s.id }
func (s nonCrossedLimitOnSellStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
if day == 2 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideSell,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeLimit,
LimitPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "2000"}},
}}, nil
}
return nil, nil
}
func makeBars(tb testing.TB) []market.Bar {
inst := market.InstrumentID("KRX:005930")
marketKRW := func(v string) market.Price {
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
}
return []market.Bar{
{InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"), Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}}},
{InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Open: marketKRW("1050"), High: marketKRW("1200"), Low: marketKRW("1000"), Close: marketKRW("1150"), Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}}},
{InstrumentID: inst, Timeframe: market.TimeframeDaily, Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), Open: marketKRW("1150"), High: marketKRW("1300"), Low: marketKRW("1100"), Close: marketKRW("1200"), Volume: market.Quantity{Amount: market.Decimal{Value: "1500"}}},
}
}
// --- tests ---
func TestEngineFillsMarketOrderOnNextBarOpen(t *testing.T) {
bars := makeBars(t)
strategyPort := &testStrategyPort{strategy: simpleBuyStrategy{id: "test-simple-buy"}}
engine := NewEngine(&testBarSource{bars: bars}, strategyPort)
startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-1",
FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(startingCash),
},
Run: backtest.Run{
ID: "run-1",
Spec: backtest.RunSpec{
StrategyID: "test-simple-buy",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: bars[0].Timestamp,
To: bars[len(bars)-1].Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// Strategy fires a market buy on bar 1; it should fill on bar 2 at bar 2's open price (1050).
if len(snap.Fills) != 1 {
t.Fatalf("expected 1 fill, got %d", len(snap.Fills))
}
fill := snap.Fills[0]
if fill.Side != backtest.OrderSideBuy {
t.Errorf("expected fill side buy, got %s", fill.Side)
}
// Market order fills at next-bar open
if fill.Price.Amount.Value != "1050" {
t.Errorf("expected fill price 1050 (bar 2 open), got %s", fill.Price.Amount.Value)
}
// Cash after buy: 10000000 - 1 * 1050 = 9998950
if snap.Account.Portfolio.Cash.Amount.Value != "9998950" {
t.Errorf("expected cash 9998950, got %s", snap.Account.Portfolio.Cash.Amount.Value)
}
// Position: 1 unit of KRX:005930
pos, ok := snap.Account.Portfolio.Position(instrumentIDFromBars(t, bars))
if !ok {
t.Fatal("expected position after fill")
}
if pos.Quantity.Amount.Value != "1" {
t.Errorf("expected position qty 1, got %s", pos.Quantity.Amount.Value)
}
}
func TestEngineFillsLimitOrderOnDailyCrossing(t *testing.T) {
// Bars: day1 O1000 H1100 L950 C1050, day3 O1150 H1300 L1100 C1200
// Strategy places limit buy at 1050 on day 1.
// Day 2 does not exist in the strategy (limitBuyStrategy orders on day 1 and 3 only) but the engine
// iterates through all bars. The pending limit buy sits until day 2 bar high (1200) crosses 1050 (sell side).
// For buy limit, we check: limit must be within [low, high]. For buy limit at 1050,
// day 2: low=1000, high=1200. 1050 is between 1000 and 1200, so it fills.
// Actually, FillOrderOnDailyBar doesn't distinguish buy/sell for limit crossing:
// it just checks if limit is within [low, high]. 1050 is within [1000, 1200].
// So it fills on bar 2 (day 2) at limit price 1050.
bars := makeBars(t)
strategyPort := &testStrategyPort{strategy: limitBuyStrategy{id: "test-limit-buy"}}
engine := NewEngine(&testBarSource{bars: bars}, strategyPort)
startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-2",
FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(startingCash),
},
Run: backtest.Run{
ID: "run-2",
Spec: backtest.RunSpec{
StrategyID: "test-limit-buy",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: bars[0].Timestamp,
To: bars[2].Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// The limit buy at 1050 should fill on bar 2 (day 2) which has low=1000, high=1200
// 1050 is between 1000 and 1200, so it crosses.
// There's also a market buy on day 3 (bar 3) which fills on bar 4 which doesn't exist.
if len(snap.Fills) < 1 {
t.Fatalf("expected at least 1 fill, got %d", len(snap.Fills))
}
// First fill should be the limit buy
limitFill := snap.Fills[0]
if limitFill.Price.Amount.Value != "1050" {
t.Errorf("expected limit fill price 1050, got %s", limitFill.Price.Amount.Value)
}
}
func TestEngineLeavesNonCrossedLimitPending(t *testing.T) {
// Bars day1: H1100, day2: H1200, day3: H1300.
// Strategy buys 1 unit on day 1 (market). Buy fills on day 2 at open 1050.
// Strategy places limit sell at 2000 on day 2. Day 2 high=1200, so limit 2000 does not cross.
// Day 3 high=1300, limit 2000 still does not cross.
// Result: order remains pending, 0 fills for the sell, and 0 rejections.
bars := makeBars(t)
strategyPort := &testStrategyPort{strategy: nonCrossedLimitOnSellStrategy{id: "test-non-crossed"}}
engine := NewEngine(&testBarSource{bars: bars}, strategyPort)
startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-3",
FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(startingCash),
},
Run: backtest.Run{
ID: "run-3",
Spec: backtest.RunSpec{
StrategyID: "test-non-crossed",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: bars[0].Timestamp,
To: bars[2].Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// The market buy from day 1 fills on day 2 (open 1050) - 1 fill.
// The limit sell from day 2 at 2000 never crosses bar high (max 1300) - does not fill.
// Total fills = 1 (just the market buy).
if len(snap.Fills) != 1 {
t.Fatalf("expected 1 fill (market buy), got %d", len(snap.Fills))
}
fill := snap.Fills[0]
if fill.Side != backtest.OrderSideBuy || fill.Price.Amount.Value != "1050" {
t.Errorf("unexpected fill: %+v", fill)
}
// No rejections: risk check passed (we have 1 unit position), just limit didn't cross.
if len(snap.Rejected) != 0 {
t.Errorf("expected 0 rejections, got %d", len(snap.Rejected))
}
// Position still holds 1 unit (sell limit didn't fill).
pos, ok := snap.Account.Portfolio.Position(instrumentIDFromBars(t, bars))
if !ok {
t.Fatal("expected position to still exist (limit sell didn't fill)")
}
if pos.Quantity.Amount.Value != "1" {
t.Errorf("expected position qty 1, got %s", pos.Quantity.Amount.Value)
}
}
func TestEngineRejectsRiskDeniedOrder(t *testing.T) {
bars := makeBars(t)
// Strategy tries to sell 2 units on day 1 but has no position.
strategyPort := &testStrategyPort{strategy: riskSellStrategy{id: "test-risk-sell"}}
engine := NewEngine(&testBarSource{bars: bars}, strategyPort)
startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-4",
FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(startingCash),
},
Run: backtest.Run{
ID: "run-4",
Spec: backtest.RunSpec{
StrategyID: "test-risk-sell",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: bars[0].Timestamp,
To: bars[2].Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// The sell order should be rejected because we have no position
if len(snap.Rejected) != 1 {
t.Fatalf("expected 1 rejected order, got %d", len(snap.Rejected))
}
rej := snap.Rejected[0]
if rej.Reason == "" {
t.Error("expected non-empty rejection reason")
}
}
func TestEngineRecordsEquitySnapshots(t *testing.T) {
bars := makeBars(t)
// buyThenSell buys 1 unit on bar 1 (day 1), sells 1 unit on bar 2 (day 2).
// Expected:
// Bar 1 (day 1): strategy fires buy on bar 1, but fills on bar 2. No fill on bar 1.
// Bar 2 (day 2): pending buy from bar 1 fills at bar 2 open (1050). Strategy fires sell on bar 2, fills on bar 3.
// Bar 3 (day 3): pending sell from bar 2 fills at bar 3 open (1150). Strategy fires no order.
// Equity at day 1: 10000000 (no position filled yet, cash unchanged)
// Equity at day 2 cash after: 10000000 - 1050 = 9998950. position 1@1050. equity = 9998950 + 1050 = 10000000
// But wait... bar 2 is also when the strategy sells. The sell fills on bar 3.
// After bar 2 fill: position 1 unit at 1050. Marked at close 1200. equity = 9998950 + 1200 = 10000150
// Bar 3: sell fills at 1150. Cash = 9998950 + 1150 = 10000100. Position = 0. equity = 10000100
//
// Actually let me trace more carefully:
// Bar 1: no pending orders -> no fills. Strategy fires buy(1 unit, market). pendingOrders = [buy]. Mark price? No position yet. Equity = 10000000.
// Bar 2: fill pending buy at bar 2 open 1050. Cash = 10000000 - 1050 = 9998950. position = 1@1050. Strategy fires sell(1 unit, market). pendingOrders = [sell]. Mark price: position exists, mark at close 1150. Equity = 9998950 + 1150 = 10000100. (Wait, but bar 2 close = 1150)
// Bar 3: fill pending sell at bar 3 open 1150. Cash = 9998950 + 1150 = 10000100. position = 0. Strategy fires no order. No position to mark. Equity = 10000100.
strategyPort := &testStrategyPort{strategy: buyThenSellStrategy{id: "test-buy-sell"}}
engine := NewEngine(&testBarSource{bars: bars}, strategyPort)
startingCash := market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-5",
FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(startingCash),
},
Run: backtest.Run{
ID: "run-5",
Spec: backtest.RunSpec{
StrategyID: "test-buy-sell",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: bars[0].Timestamp,
To: bars[2].Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
if len(snap.EquityCurve) != 3 {
t.Fatalf("expected 3 equity points, got %d", len(snap.EquityCurve))
}
// Bar 1: no position filled yet (strategy buy fires on bar 1 but fills on bar 2)
if snap.EquityCurve[0].Equity.Amount.Value != "10000000" {
t.Errorf("equity at bar 1: expected 10000000, got %s", snap.EquityCurve[0].Equity.Amount.Value)
}
// Bar 2: buy filled at 1050. position 1 unit. Mark at close 1150.
// equity = 9998950 + 1150 = 10000100
wantEq2 := "10000100"
if snap.EquityCurve[1].Equity.Amount.Value != wantEq2 {
t.Errorf("equity at bar 2: expected %s, got %s", wantEq2, snap.EquityCurve[1].Equity.Amount.Value)
}
// Bar 3: sell filled at 1150. No position. equity = 10000100
wantEq3 := "10000100"
if snap.EquityCurve[2].Equity.Amount.Value != wantEq3 {
t.Errorf("equity at bar 3: expected %s, got %s", wantEq3, snap.EquityCurve[2].Equity.Amount.Value)
}
// Should have exactly 2 fills
if len(snap.Fills) != 2 {
t.Errorf("expected 2 fills, got %d", len(snap.Fills))
}
}
// --- auxiliary ---
// riskSellStrategy places a sell order on the first bar without any position.
type riskSellStrategy struct {
id backtest.StrategyID
}
func (s riskSellStrategy) ID() backtest.StrategyID { return s.id }
func (s riskSellStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideSell,
Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
func instrumentIDFromBars(tb testing.TB, bars []market.Bar) market.InstrumentID {
if len(bars) == 0 {
tb.Fatal("empty bars")
}
return bars[0].InstrumentID
}
// multiInstBuyStrategy buys on the first bar of each instrument it sees.
type multiInstBuyStrategy struct {
id backtest.StrategyID
}
func (s multiInstBuyStrategy) ID() backtest.StrategyID { return s.id }
func (s multiInstBuyStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
// TestEngineMultiInstrumentPendingIsScoped verifies that a pending order for
// instrument A cannot be filled by a bar belonging to instrument B on the same
// timestamp.
func TestEngineMultiInstrumentPendingIsScoped(t *testing.T) {
marketKRW := func(v string) market.Price {
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
}
instA := market.InstrumentID("KRX:000660")
instB := market.InstrumentID("KRX:005930")
// Both on same timestamp. Strategy fires buy on day 1 for both instruments.
// After sorting by timestamp then instrument ID: instA, instB, instA day2, instB day2
// instA day1 → pendingA=[buyA]. instB day1 → pendingB=[buyB].
// instA day2 → pendingA buyA fills at instA.day2.open=1000.
// instB day2 → pendingB buyB fills at instB.day2.open=900.
day1A := market.Bar{
InstrumentID: instA, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}},
}
day1B := market.Bar{
InstrumentID: instB, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Open: marketKRW("900"), High: marketKRW("950"), Low: marketKRW("850"), Close: marketKRW("920"),
Volume: market.Quantity{Amount: market.Decimal{Value: "800"}},
}
// instA day2 must NOT be consumed by instB pending
day2A := market.Bar{
InstrumentID: instA, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1100"), High: marketKRW("1200"), Low: marketKRW("1050"), Close: marketKRW("1150"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}},
}
day2B := market.Bar{
InstrumentID: instB, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Open: marketKRW("2000"), High: marketKRW("2100"), Low: marketKRW("1900"), Close: marketKRW("2050"),
Volume: market.Quantity{Amount: market.Decimal{Value: "900"}},
}
bars := []market.Bar{day1A, day1B, day2A, day2B}
engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: multiInstBuyStrategy{id: "test-multi"}})
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-multi", FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}),
},
Run: backtest.Run{
ID: "run-multi",
Spec: backtest.RunSpec{
StrategyID: "test-multi", Market: market.MarketKR, Timeframe: market.TimeframeDaily,
From: day1A.Timestamp, To: day2B.Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
if len(snap.Fills) != 2 {
t.Fatalf("expected 2 fills, got %d", len(snap.Fills))
}
// Fills occur on the NEXT bar of the same instrument at that bar's open.
// pendingA buys on day1A, fills on day2A.open=1100.
// pendingB buys on day1B, fills on day2B.open=2000.
if snap.Fills[0].InstrumentID != instA || snap.Fills[0].Price.Amount.Value != "1100" {
t.Errorf("expected A fill at 1100 (day2A open), got %+v", snap.Fills[0])
}
if snap.Fills[1].InstrumentID != instB || snap.Fills[1].Price.Amount.Value != "2000" {
t.Errorf("expected B fill at 2000 (day2B open), got %+v", snap.Fills[1])
}
}
// TestEngineApplyFillFailureIsRejected verifies that a market buy that passes
// CheckRisk but fails ApplyFill (insufficient cash) ends up as RejectedOrder
// and is NOT retried on future bars.
func TestEngineApplyFillFailureIsRejected(t *testing.T) {
marketKRW := func(v string) market.Price {
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
}
inst := market.InstrumentID("KRX:005930")
bars := []market.Bar{
{InstrumentID: inst, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}},
},
{InstrumentID: inst, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1050"), High: marketKRW("1200"), Low: marketKRW("1000"), Close: marketKRW("1150"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}},
},
{InstrumentID: inst, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1150"), High: marketKRW("1300"), Low: marketKRW("1100"), Close: marketKRW("1200"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1500"}},
},
}
engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: &buyLargeStrategy{id: "test-buy-large"}})
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-reject", FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "500"}}),
},
Run: backtest.Run{
ID: "run-reject",
Spec: backtest.RunSpec{
StrategyID: "test-buy-large", Market: market.MarketKR, Timeframe: market.TimeframeDaily,
From: bars[0].Timestamp, To: bars[2].Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
if len(snap.Rejected) != 1 {
t.Fatalf("expected 1 rejected, got %d", len(snap.Rejected))
}
if snap.Rejected[0].Reason == "" {
t.Error("expected non-empty rejection reason")
}
if len(snap.Fills) != 0 {
t.Errorf("expected 0 fills, got %d", len(snap.Fills))
}
if snap.Account.Portfolio.Cash.Amount.Value != "500" {
t.Errorf("expected cash 500 unchanged, got %s", snap.Account.Portfolio.Cash.Amount.Value)
}
}
// buyLargeStrategy returns a buy order for 9999999 units only on the first bar (will fail ApplyFill).
type buyLargeStrategy struct {
id backtest.StrategyID
}
func (s buyLargeStrategy) ID() backtest.StrategyID { return s.id }
func (s buyLargeStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "9999999"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
// orderABStrategy returns an order for instrument B while processing
// the first bar of instrument A.
type orderABStrategy struct {
id backtest.StrategyID
aInst market.InstrumentID
bInst market.InstrumentID
}
func (s orderABStrategy) ID() backtest.StrategyID { return s.id }
func (s orderABStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
day := input.Bar.Timestamp.Day()
if day == 1 && input.Bar.InstrumentID == s.aInst {
return []backtest.OrderIntent{{
InstrumentID: s.bInst,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: backtest.OrderTypeMarket,
}}, nil
}
return nil, nil
}
// TestEngineCrossInstrumentPendingKeyed verifies that a strategy can return an
// order for a different instrument than the current bar, and that order fills
// only on the matching instrument's next bar.
func TestEngineCrossInstrumentPendingKeyed(t *testing.T) {
marketKRW := func(v string) market.Price {
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
}
instA := market.InstrumentID("KRX:000660")
instB := market.InstrumentID("KRX:005930")
// Two bars with different timestamps: instA on day1, instB on day2.
// A bar day1 → strategy fires B-order, stored in pendingOrders[B].
// B bar day2 → Phase 1 picks up B-order, fills at day2B.open=950.
// Key invariant: B-order stored during A bar fills at B price, not A price.
day1A := market.Bar{
InstrumentID: instA, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}},
}
day2B := market.Bar{
InstrumentID: instB, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Open: marketKRW("950"), High: marketKRW("1000"), Low: marketKRW("900"), Close: marketKRW("970"),
Volume: market.Quantity{Amount: market.Decimal{Value: "900"}},
}
bars := []market.Bar{day1A, day2B}
// Strategy returns a B-order on day 1 (while the bar is for instrument A)
engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: orderABStrategy{id: "test-cross-inst", aInst: instA, bInst: instB}})
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-cross", FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}),
},
Run: backtest.Run{
ID: "run-cross",
Spec: backtest.RunSpec{
StrategyID: "test-cross-inst", Market: market.MarketKR, Timeframe: market.TimeframeDaily,
From: day1A.Timestamp, To: day2B.Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// The B-order fires during A's day1 processing. It sits in pendingOrders[instB]
// and only fills when the next instB bar arrives on day2. The fill price is
// day2B.open=950. A's bars never consume orders for B.
if len(snap.Fills) != 1 {
t.Fatalf("expected 1 fill from cross-instrument order, got %d", len(snap.Fills))
}
fill := snap.Fills[0]
if fill.InstrumentID != instB {
t.Fatalf("expected fill instrument %s, got %s", instB, fill.InstrumentID)
}
if fill.Price.Amount.Value != "950" {
t.Errorf("expected fill price 950 (B bar open), got %s", fill.Price.Amount.Value)
}
}
// TestEngineEmptyInstrumentIDRejected verifies that an order with empty
// InstrumentID is rejected rather than silently falling back to the current bar's instrument.
func TestEngineEmptyInstrumentIDRejected(t *testing.T) {
marketKRW := func(v string) market.Price {
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
}
inst := market.InstrumentID("KRX:005930")
day1 := market.Bar{
InstrumentID: inst, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1000"), High: marketKRW("1100"), Low: marketKRW("950"), Close: marketKRW("1050"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1000"}},
}
day2 := market.Bar{
InstrumentID: inst, Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Open: marketKRW("1050"), High: marketKRW("1200"), Low: marketKRW("1000"), Close: marketKRW("1150"),
Volume: market.Quantity{Amount: market.Decimal{Value: "1200"}},
}
bars := []market.Bar{day1, day2}
engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: emptyInstOrderStrategy{id: "test-empty-inst"}})
req := RunRequest{
Account: backtest.PaperAccount{
ID: "paper-empty-inst", FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}),
},
Run: backtest.Run{
ID: "run-empty-inst",
Spec: backtest.RunSpec{
StrategyID: "test-empty-inst", Market: market.MarketKR, Timeframe: market.TimeframeDaily,
From: day1.Timestamp, To: day2.Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// Empty InstrumentID → rejected, never retried on day 2
if len(snap.Rejected) != 1 {
t.Fatalf("expected 1 rejected order for empty InstrumentID, got %d", len(snap.Rejected))
}
if snap.Rejected[0].Reason != "empty order.InstrumentID" {
t.Errorf("expected reason 'empty order.InstrumentID', got %q", snap.Rejected[0].Reason)
}
if len(snap.Fills) != 0 {
t.Errorf("expected 0 fills for rejected empty-Inst order, got %d", len(snap.Fills))
}
}
// TestEnginePreservesAccountIdentity verifies that RunRequest.Account.ID is
// preserved in Snapshot.Account.ID regardless of Run.ID.
func TestEnginePreservesAccountIdentity(t *testing.T) {
bars := makeBars(t)
const paperID = "my-paper-account-42"
const runID = "run-unique-run-id"
engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: simpleBuyStrategy{id: "test-acct"}})
req := RunRequest{
Account: backtest.PaperAccount{
ID: backtest.PaperAccountID(paperID), FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
Portfolio: backtest.NewPortfolioState(market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}}),
},
Run: backtest.Run{
ID: backtest.RunID(runID),
Spec: backtest.RunSpec{
StrategyID: "test-acct", Market: market.MarketKR, Timeframe: market.TimeframeDaily,
From: bars[0].Timestamp, To: bars[2].Timestamp,
},
},
}
snap, err := engine.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run failed: %v", err)
}
if string(snap.Account.ID) != paperID {
t.Errorf("expected account ID %q, got %q", paperID, snap.Account.ID)
}
}

View file

@ -0,0 +1,141 @@
package papertrading
import (
"context"
"errors"
"fmt"
"sort"
"sync"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
// ErrAccountNotFound is returned when a paper trading state is requested for an
// account that has not been started in this process.
var ErrAccountNotFound = errors.New("paper trading account not found")
// 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.
type StartRequest struct {
AccountID backtest.PaperAccountID
Spec backtest.RunSpec
StartingCash market.Price
}
// State is the terminal snapshot of a paper account after a run. Positions are
// kept as a stable, instrument-sorted slice so socket output is deterministic.
type State struct {
Run backtest.Run
Cash market.Price
Positions []backtest.Position
Fills []backtest.Fill
EquityCurve []backtest.EquityPoint
}
// Service is the worker-owned, process-local paper trading runtime. It runs the
// daily paper Engine on start and keeps the resulting state in memory keyed by
// account id so the operator can inspect it through the API/CLI without a
// durable store. Paper readiness is intentionally in-memory: the milestone only
// needs deterministic start/inspect, not persistence.
type Service struct {
engine *Engine
now func() time.Time
mu sync.Mutex
states map[backtest.PaperAccountID]State
}
// NewService builds a paper trading service around an Engine. now defaults to
// time.Now when nil so callers can inject a clock in tests.
func NewService(engine *Engine, now func() time.Time) *Service {
if now == nil {
now = time.Now
}
return &Service{
engine: engine,
now: now,
states: make(map[backtest.PaperAccountID]State),
}
}
// StartPaperTrading runs a paper simulation for the request and records the
// terminal state under the account id, replacing any earlier state for that
// account. The run is executed synchronously: unlike a backtest it is a bounded
// daily replay over already-imported bars, so the operator gets the terminal
// account state in the start response.
func (s *Service) StartPaperTrading(ctx context.Context, req StartRequest) (State, error) {
if s == nil || s.engine == nil {
return State{}, fmt.Errorf("paper trading engine is not configured")
}
if req.AccountID == "" {
return State{}, fmt.Errorf("account_id is required")
}
now := s.now().UTC()
run := backtest.Run{
ID: backtest.RunID(fmt.Sprintf("paper-%s-%d", req.AccountID, now.UnixNano())),
Spec: req.Spec,
Status: backtest.RunStatusRunning,
CreatedAt: now,
UpdatedAt: now,
}
account := backtest.PaperAccount{
ID: req.AccountID,
Portfolio: backtest.NewPortfolioState(req.StartingCash),
UpdatedAt: now,
FillPolicy: backtest.FillPolicyDailyNextBarOHLC,
RiskSettings: backtest.RiskSettings{AllowShortSelling: false},
}
snap, err := s.engine.Run(ctx, RunRequest{Account: account, Run: run})
if err != nil {
return State{}, fmt.Errorf("paper run failed: %w", err)
}
run.Status = backtest.RunStatusSucceeded
run.UpdatedAt = s.now().UTC()
state := stateFromSnapshot(run, snap)
s.mu.Lock()
s.states[req.AccountID] = state
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.
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()
state, ok := s.states[accountID]
if !ok {
return State{}, ErrAccountNotFound
}
return state, nil
}
// stateFromSnapshot flattens the engine snapshot into the inspectable State,
// sorting positions by instrument id for stable output.
func stateFromSnapshot(run backtest.Run, snap *Snapshot) State {
positions := make([]backtest.Position, 0, len(snap.Account.Portfolio.Positions))
for _, p := range snap.Account.Portfolio.Positions {
positions = append(positions, p)
}
sort.Slice(positions, func(i, j int) bool {
return positions[i].InstrumentID < positions[j].InstrumentID
})
return State{
Run: run,
Cash: snap.Account.Portfolio.Cash,
Positions: positions,
Fills: snap.Fills,
EquityCurve: snap.EquityCurve,
}
}

View file

@ -0,0 +1,89 @@
package papertrading
import (
"context"
"errors"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
func serviceTestRequest() StartRequest {
return StartRequest{
AccountID: "paper-acct-1",
Spec: backtest.RunSpec{
StrategyID: "test-simple-buy",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
},
StartingCash: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "10000000"}},
}
}
func newTestService() *Service {
bars := makeBars(nil)
engine := NewEngine(&testBarSource{bars: bars}, &testStrategyPort{strategy: simpleBuyStrategy{id: "test-simple-buy"}})
fixed := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
return NewService(engine, func() time.Time { return fixed })
}
func TestServiceStartRecordsState(t *testing.T) {
svc := newTestService()
state, err := svc.StartPaperTrading(context.Background(), serviceTestRequest())
if err != nil {
t.Fatalf("start failed: %v", err)
}
if state.Run.Status != backtest.RunStatusSucceeded {
t.Errorf("expected succeeded run status, got %q", state.Run.Status)
}
if state.Run.ID == "" {
t.Error("expected a non-empty run id")
}
// simpleBuyStrategy buys 1 unit on day 1, fills on day 2 at open 1050.
if len(state.Fills) != 1 {
t.Fatalf("expected 1 fill, got %d", len(state.Fills))
}
if state.Cash.Amount.Value != "9998950" {
t.Errorf("expected cash 9998950, got %s", state.Cash.Amount.Value)
}
if len(state.Positions) != 1 {
t.Errorf("expected 1 position, got %d", len(state.Positions))
}
}
func TestServiceGetStateReturnsRecorded(t *testing.T) {
svc := newTestService()
if _, err := svc.StartPaperTrading(context.Background(), serviceTestRequest()); err != nil {
t.Fatalf("start failed: %v", err)
}
state, err := svc.GetPaperTradingState(context.Background(), "paper-acct-1")
if err != nil {
t.Fatalf("get state failed: %v", err)
}
if state.Run.Status != backtest.RunStatusSucceeded {
t.Errorf("expected succeeded run status, got %q", state.Run.Status)
}
}
func TestServiceGetStateNotFound(t *testing.T) {
svc := newTestService()
_, err := svc.GetPaperTradingState(context.Background(), "never-started")
if !errors.Is(err, ErrAccountNotFound) {
t.Fatalf("expected ErrAccountNotFound, got %v", err)
}
}
func TestServiceStartRequiresAccountID(t *testing.T) {
svc := newTestService()
req := serviceTestRequest()
req.AccountID = ""
if _, err := svc.StartPaperTrading(context.Background(), req); err == nil {
t.Fatal("expected error for empty account id")
}
}

View file

@ -50,6 +50,7 @@ type Deps struct {
Instruments storage.InstrumentStore
Bars storage.BarStore
DailyBarImporter DailyBarImporter
Paper PaperService
}
// BacktestDeps is retained for existing call sites while the socket dependency

View file

@ -20,6 +20,7 @@ func sessionHandlers(deps Deps) []sessionHandler {
}
handlers = append(handlers, marketHandlers(deps)...)
handlers = append(handlers, backtestHandlers(deps)...)
handlers = append(handlers, paperHandlers(deps)...)
return handlers
}
@ -82,5 +83,8 @@ func capabilitiesForDeps(deps Deps) []string {
if deps.Starter != nil {
caps = append(caps, "backtest-start", "worker-execution")
}
if deps.Paper != nil {
caps = append(caps, "paper-trading")
}
return caps
}

View file

@ -0,0 +1,108 @@
package socket
import (
"context"
"errors"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
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/services/worker/internal/papertrading"
)
// PaperService is the worker-owned paper trading runtime the socket layer drives.
// The papertrading package provides the concrete process-local implementation;
// the narrow interface keeps the handlers testable with a fake.
type PaperService interface {
StartPaperTrading(ctx context.Context, req papertrading.StartRequest) (papertrading.State, error)
GetPaperTradingState(ctx context.Context, accountID backtest.PaperAccountID) (papertrading.State, error)
}
// paperHandlers returns the session handlers for the paper trading command and
// query surface, each closing over the shared dependencies.
func paperHandlers(deps Deps) []sessionHandler {
return []sessionHandler{
{
requestType: protoSocket.TypeNameOf(&altv1.StartPaperTradingRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](
&client.Communicator,
func(req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
return handleStartPaperTrading(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetPaperTradingStateRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetPaperTradingStateRequest, *altv1.GetPaperTradingStateResponse](
&client.Communicator,
func(req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
return handleGetPaperTradingState(deps, req)
},
)
},
},
}
}
func handleStartPaperTrading(deps Deps, req *altv1.StartPaperTradingRequest) (*altv1.StartPaperTradingResponse, error) {
startReq, err := startPaperRequestFromProto(req)
if err != nil {
return &altv1.StartPaperTradingResponse{Error: invalidPaperRequest(err.Error())}, nil
}
if deps.Paper == nil {
return &altv1.StartPaperTradingResponse{Error: unavailableError("paper trading is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
state, err := deps.Paper.StartPaperTrading(ctx, startReq)
if err != nil {
return &altv1.StartPaperTradingResponse{Error: paperBackendErrorInfo(err)}, nil
}
return &altv1.StartPaperTradingResponse{State: paperStateToProto(startReq.AccountID, state)}, nil
}
func handleGetPaperTradingState(deps Deps, req *altv1.GetPaperTradingStateRequest) (*altv1.GetPaperTradingStateResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetPaperTradingStateResponse{Error: invalidPaperRequest("account_id is required")}, nil
}
if deps.Paper == nil {
return &altv1.GetPaperTradingStateResponse{Error: unavailableError("paper trading is not available")}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
accountID := backtest.PaperAccountID(req.GetAccountId())
state, err := deps.Paper.GetPaperTradingState(ctx, accountID)
if err != nil {
return &altv1.GetPaperTradingStateResponse{Error: paperBackendErrorInfo(err)}, nil
}
return &altv1.GetPaperTradingStateResponse{State: paperStateToProto(accountID, state)}, nil
}
func invalidPaperRequest(reason string) *altv1.ErrorInfo {
return errorInfo(backtestErrorInvalidRequest, "invalid paper trading request: "+reason)
}
// paperBackendErrorInfo maps paper runtime errors onto the shared typed error
// vocabulary: a missing account is not_found, a deadline is timeout, everything
// else is internal.
func paperBackendErrorInfo(err error) *altv1.ErrorInfo {
if err == nil {
return nil
}
switch {
case errors.Is(err, papertrading.ErrAccountNotFound):
return errorInfo(backtestErrorNotFound, err.Error())
case errors.Is(err, context.DeadlineExceeded):
return errorInfo(backtestErrorTimeout, err.Error())
default:
return errorInfo(backtestErrorInternal, err.Error())
}
}

View file

@ -0,0 +1,112 @@
package socket
import (
"fmt"
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"
)
// paper_mapping.go owns paper-trading-specific conversions between the ALT
// contract and the worker-owned paper runtime. As with backtest mapping, keeping
// proto<->domain conversion in the worker honours the rail design where the
// worker owns runtime shapes and the API stays a thin pass-through.
// startPaperRequestFromProto validates an inbound StartPaperTradingRequest and
// converts it into the paper service start request. Validation lives next to the
// conversion so a malformed request never reaches the runtime.
func startPaperRequestFromProto(req *altv1.StartPaperTradingRequest) (papertrading.StartRequest, error) {
if req.GetAccountId() == "" {
return papertrading.StartRequest{}, fmt.Errorf("account_id is required")
}
spec, err := runSpecFromProto(req.GetSpec())
if err != nil {
return papertrading.StartRequest{}, err
}
cash, err := priceFromProto(req.GetStartingCash())
if err != nil {
return papertrading.StartRequest{}, fmt.Errorf("starting_cash: %w", err)
}
return papertrading.StartRequest{
AccountID: backtest.PaperAccountID(req.GetAccountId()),
Spec: spec,
StartingCash: cash,
}, nil
}
// priceFromProto converts a contract Price into the domain Price, requiring a
// concrete currency and amount so paper cash math never runs on an empty value.
func priceFromProto(price *altv1.Price) (market.Price, error) {
if price == nil {
return market.Price{}, fmt.Errorf("price is required")
}
currency, err := currencyFromProto(price.GetCurrency())
if err != nil {
return market.Price{}, err
}
if price.GetAmount().GetValue() == "" {
return market.Price{}, fmt.Errorf("amount is required")
}
return market.Price{
Currency: currency,
Amount: market.Decimal{Value: price.GetAmount().GetValue()},
}, nil
}
func currencyFromProto(c altv1.Currency) (market.Currency, error) {
switch c {
case altv1.Currency_CURRENCY_KRW:
return market.CurrencyKRW, nil
case altv1.Currency_CURRENCY_USD:
return market.CurrencyUSD, nil
default:
return "", fmt.Errorf("unsupported currency %q", c.String())
}
}
// paperStateToProto converts a paper service State into the contract state,
// 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),
}
}
func paperPositionsToProto(positions []backtest.Position) []*altv1.BacktestPosition {
if len(positions) == 0 {
return nil
}
out := make([]*altv1.BacktestPosition, len(positions))
for i, p := range positions {
out[i] = &altv1.BacktestPosition{
InstrumentId: string(p.InstrumentID),
Quantity: quantityToProto(p.Quantity),
LastPrice: priceToProto(p.LastPrice),
}
}
return out
}
func fillsToProto(fills []backtest.Fill) []*altv1.BacktestTrade {
if len(fills) == 0 {
return nil
}
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),
}
}
return out
}

View file

@ -0,0 +1,251 @@
package socket
import (
"context"
"errors"
"testing"
"time"
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"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
)
type fakePaperService struct {
gotStart papertrading.StartRequest
gotAccount backtest.PaperAccountID
state papertrading.State
startErr error
stateErr error
}
func (f *fakePaperService) StartPaperTrading(ctx context.Context, req papertrading.StartRequest) (papertrading.State, error) {
f.gotStart = req
if f.startErr != nil {
return papertrading.State{}, f.startErr
}
return f.state, nil
}
func (f *fakePaperService) GetPaperTradingState(ctx context.Context, accountID backtest.PaperAccountID) (papertrading.State, error) {
f.gotAccount = accountID
if f.stateErr != nil {
return papertrading.State{}, f.stateErr
}
return f.state, nil
}
func krw(v string) market.Price {
return market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: v}}
}
func validStartPaperRequest() *altv1.StartPaperTradingRequest {
return &altv1.StartPaperTradingRequest{
AccountId: "paper-1",
Spec: &altv1.BacktestRunSpec{
StrategyId: "strategy-v1",
Market: altv1.Market_MARKET_KR,
Timeframe: altv1.Timeframe_TIMEFRAME_DAILY,
FromUnixMs: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC).UnixMilli(),
ToUnixMs: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC).UnixMilli(),
},
StartingCash: &altv1.Price{
Currency: altv1.Currency_CURRENCY_KRW,
Amount: &altv1.Decimal{Value: "10000000"},
},
}
}
func sampleState() papertrading.State {
return papertrading.State{
Run: backtest.Run{
ID: "paper-run-1",
Status: backtest.RunStatusSucceeded,
Spec: backtest.RunSpec{StrategyID: "strategy-v1", Market: market.MarketKR, Timeframe: market.TimeframeDaily},
},
Cash: krw("9998950"),
Positions: []backtest.Position{
{InstrumentID: "KRX:005930", Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, LastPrice: krw("1150")},
},
Fills: []backtest.Fill{
{InstrumentID: "KRX:005930", Side: backtest.OrderSideBuy, Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}}, Price: krw("1050"), Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)},
},
EquityCurve: []backtest.EquityPoint{
{Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), Equity: krw("10000100")},
},
}
}
func TestHandleStartPaperTradingSuccess(t *testing.T) {
paper := &fakePaperService{state: sampleState()}
deps := Deps{Paper: paper}
resp, err := handleStartPaperTrading(deps, validStartPaperRequest())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
st := resp.GetState()
if st.GetAccountId() != "paper-1" {
t.Errorf("account id mismatch: %q", st.GetAccountId())
}
if st.GetRun().GetStatus() != altv1.BacktestRunStatus_BACKTEST_RUN_STATUS_SUCCEEDED {
t.Errorf("expected succeeded run status, got %v", st.GetRun().GetStatus())
}
if st.GetCash().GetAmount().GetValue() != "9998950" {
t.Errorf("cash mismatch: %q", st.GetCash().GetAmount().GetValue())
}
if len(st.GetPositions()) != 1 || st.GetPositions()[0].GetInstrumentId() != "KRX:005930" {
t.Errorf("positions mismatch: %+v", st.GetPositions())
}
if len(st.GetFills()) != 1 || st.GetFills()[0].GetPrice().GetAmount().GetValue() != "1050" {
t.Errorf("fills mismatch: %+v", st.GetFills())
}
if len(st.GetEquityCurve()) != 1 {
t.Errorf("equity curve mismatch: %+v", st.GetEquityCurve())
}
if paper.gotStart.AccountID != "paper-1" || paper.gotStart.StartingCash.Amount.Value != "10000000" {
t.Errorf("service received wrong start request: %+v", paper.gotStart)
}
}
func TestHandleStartPaperTradingInvalidRequest(t *testing.T) {
deps := Deps{Paper: &fakePaperService{}}
// missing account_id
resp, err := handleStartPaperTrading(deps, &altv1.StartPaperTradingRequest{
Spec: validStartPaperRequest().GetSpec(),
StartingCash: validStartPaperRequest().GetStartingCash(),
})
if err != nil {
t.Fatalf("expected typed validation response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
// missing starting cash
resp, err = handleStartPaperTrading(deps, &altv1.StartPaperTradingRequest{
AccountId: "paper-1",
Spec: validStartPaperRequest().GetSpec(),
})
if err != nil {
t.Fatalf("expected typed validation response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
}
func TestHandleStartPaperTradingUnavailable(t *testing.T) {
resp, err := handleStartPaperTrading(Deps{}, validStartPaperRequest())
if err != nil {
t.Fatalf("expected typed unavailable response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
}
func TestHandleStartPaperTradingServiceError(t *testing.T) {
deps := Deps{Paper: &fakePaperService{startErr: errors.New("boom")}}
resp, err := handleStartPaperTrading(deps, validStartPaperRequest())
if err != nil {
t.Fatalf("expected typed internal response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInternal)
}
func TestHandleGetPaperTradingStateSuccess(t *testing.T) {
paper := &fakePaperService{state: sampleState()}
deps := Deps{Paper: paper}
resp, err := handleGetPaperTradingState(deps, &altv1.GetPaperTradingStateRequest{AccountId: "paper-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if resp.GetState().GetAccountId() != "paper-1" {
t.Errorf("account id mismatch: %q", resp.GetState().GetAccountId())
}
if paper.gotAccount != "paper-1" {
t.Errorf("service received wrong account id: %q", paper.gotAccount)
}
}
func TestHandleGetPaperTradingStateNotFound(t *testing.T) {
deps := Deps{Paper: &fakePaperService{stateErr: papertrading.ErrAccountNotFound}}
resp, err := handleGetPaperTradingState(deps, &altv1.GetPaperTradingStateRequest{AccountId: "missing"})
if err != nil {
t.Fatalf("expected typed not_found response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorNotFound)
}
func TestHandleGetPaperTradingStateRequiresAccountID(t *testing.T) {
deps := Deps{Paper: &fakePaperService{}}
resp, err := handleGetPaperTradingState(deps, &altv1.GetPaperTradingStateRequest{})
if err != nil {
t.Fatalf("expected typed validation response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorInvalidRequest)
}
func TestHandleGetPaperTradingStateUnavailable(t *testing.T) {
resp, err := handleGetPaperTradingState(Deps{}, &altv1.GetPaperTradingStateRequest{AccountId: "paper-1"})
if err != nil {
t.Fatalf("expected typed unavailable response, got error: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
}
func TestPaperHandlersCoverAllRequests(t *testing.T) {
registered := make(map[string]bool)
for _, h := range paperHandlers(Deps{}) {
if h.requestType == "" {
t.Error("handler has empty request type")
}
if h.register == nil {
t.Errorf("handler %q has nil register", h.requestType)
}
registered[h.requestType] = true
}
for _, req := range []string{"alt.v1.StartPaperTradingRequest", "alt.v1.GetPaperTradingStateRequest"} {
if !registered[req] {
t.Errorf("missing handler for %q", req)
}
}
}
func TestWorkerPaperSocketUnavailableReturnsTypedError(t *testing.T) {
client := startBacktestWorkerTestClient(t, Deps{})
resp, err := protoSocket.SendRequestTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](
&client.Communicator,
validStartPaperRequest(),
500*time.Millisecond,
)
if err != nil {
t.Fatalf("request should return typed error response without timeout: %v", err)
}
requireBacktestError(t, resp.GetError(), backtestErrorUnavailable)
}
func TestWorkerPaperSocketStartRoundTrip(t *testing.T) {
client := startBacktestWorkerTestClient(t, Deps{Paper: &fakePaperService{state: sampleState()}})
resp, err := protoSocket.SendRequestTyped[*altv1.StartPaperTradingRequest, *altv1.StartPaperTradingResponse](
&client.Communicator,
validStartPaperRequest(),
time.Second,
)
if err != nil {
t.Fatalf("round trip failed: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if resp.GetState().GetAccountId() != "paper-1" {
t.Errorf("account id mismatch: %q", resp.GetState().GetAccountId())
}
}