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:
parent
51f8a6b2ae
commit
aaf1af6387
55 changed files with 5217 additions and 281 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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]
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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`를 작성한다.
|
||||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{} },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
191
apps/cli/internal/operator/runner_paper_test.go
Normal file
191
apps/cli/internal/operator/runner_paper_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
apps/cli/testdata/operator/expected/paper_trading_state.jsonl
vendored
Normal file
4
apps/cli/testdata/operator/expected/paper_trading_state.jsonl
vendored
Normal 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"}
|
||||
34
apps/cli/testdata/operator/paper_trading_state.yaml
vendored
Normal file
34
apps/cli/testdata/operator/paper_trading_state.yaml
vendored
Normal 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
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
421
apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart
Normal file
421
apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart
Normal 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');
|
||||
|
|
@ -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
|
||||
169
apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart
Normal file
169
apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart
Normal 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==');
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
415
packages/contracts/gen/go/alt/v1/paper_trading.pb.go
Normal file
415
packages/contracts/gen/go/alt/v1/paper_trading.pb.go
Normal 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
|
||||
}
|
||||
43
packages/contracts/proto/alt/v1/paper_trading.proto
Normal file
43
packages/contracts/proto/alt/v1/paper_trading.proto
Normal 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;
|
||||
}
|
||||
|
|
@ -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{} },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
84
services/api/internal/socket/paper.go
Normal file
84
services/api/internal/socket/paper.go
Normal 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
|
||||
}
|
||||
193
services/api/internal/socket/paper_test.go
Normal file
193
services/api/internal/socket/paper_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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{} },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
228
services/worker/internal/papertrading/engine.go
Normal file
228
services/worker/internal/papertrading/engine.go
Normal 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
|
||||
}
|
||||
821
services/worker/internal/papertrading/engine_test.go
Normal file
821
services/worker/internal/papertrading/engine_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
141
services/worker/internal/papertrading/service.go
Normal file
141
services/worker/internal/papertrading/service.go
Normal 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,
|
||||
}
|
||||
}
|
||||
89
services/worker/internal/papertrading/service_test.go
Normal file
89
services/worker/internal/papertrading/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
108
services/worker/internal/socket/paper.go
Normal file
108
services/worker/internal/socket/paper.go
Normal 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())
|
||||
}
|
||||
}
|
||||
112
services/worker/internal/socket/paper_mapping.go
Normal file
112
services/worker/internal/socket/paper_mapping.go
Normal 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
|
||||
}
|
||||
251
services/worker/internal/socket/paper_test.go
Normal file
251
services/worker/internal/socket/paper_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue