diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G07_0.log b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G07_0.log new file mode 100644 index 0000000..4413913 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G07_0.log @@ -0,0 +1,196 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 +``` diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G08_1.log b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G08_1.log new file mode 100644 index 0000000..da7478d --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G08_1.log @@ -0,0 +1,212 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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] +``` diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G09_2.log b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G09_2.log new file mode 100644 index 0000000..29a6b3f --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/code_review_local_G09_2.log @@ -0,0 +1,228 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 +``` diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/complete.log b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/complete.log new file mode 100644 index 0000000..e61d6ca --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G07.md b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G07_0.log similarity index 94% rename from agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G07.md rename to agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G07_0.log index 740e07d..60d1720 100644 --- a/agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/PLAN-local-G07.md +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G07_0.log @@ -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. ### 테스트 작성 diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G08_1.log b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G08_1.log new file mode 100644 index 0000000..0d60cc4 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G08_1.log @@ -0,0 +1,109 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G09_2.log b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G09_2.log new file mode 100644 index 0000000..a278ddb --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/02+01_worker_paper_runtime/plan_local_G09_2.log @@ -0,0 +1,100 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_cloud_G07_0.log b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_cloud_G07_0.log new file mode 100644 index 0000000..b17d746 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_cloud_G07_0.log @@ -0,0 +1,216 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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="/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="/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="/bin:$PATH" bin/contracts-gen +exit=0 + +$ PATH="/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="/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`를 작성한다. diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_local_G07_1.log b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_local_G07_1.log new file mode 100644 index 0000000..0d39836 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/code_review_local_G07_1.log @@ -0,0 +1,179 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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=/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로 이동한다. diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/complete.log b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/complete.log new file mode 100644 index 0000000..3245b9f --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/PLAN-cloud-G07.md b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_cloud_G07_0.log similarity index 100% rename from agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/PLAN-cloud-G07.md rename to agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_cloud_G07_0.log diff --git a/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_local_G07_1.log b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_local_G07_1.log new file mode 100644 index 0000000..f084ad2 --- /dev/null +++ b/agent-task/archive/2026/06/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/plan_local_G07_1.log @@ -0,0 +1,105 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G07.md b/agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G07.md deleted file mode 100644 index 4bd6320..0000000 --- a/agent-task/m-paper-trading-readiness/02+01_worker_paper_runtime/CODE_REVIEW-local-G07.md +++ /dev/null @@ -1,116 +0,0 @@ - - -# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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. diff --git a/agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/CODE_REVIEW-cloud-G07.md b/agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/CODE_REVIEW-cloud-G07.md deleted file mode 100644 index de0df57..0000000 --- a/agent-task/m-paper-trading-readiness/03+01,02_contract_api_cli_monitoring/CODE_REVIEW-cloud-G07.md +++ /dev/null @@ -1,155 +0,0 @@ - - -# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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. diff --git a/apps/cli/internal/operator/client.go b/apps/cli/internal/operator/client.go index 54a138a..3e31910 100644 --- a/apps/cli/internal/operator/client.go +++ b/apps/cli/internal/operator/client.go @@ -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. diff --git a/apps/cli/internal/operator/client_test.go b/apps/cli/internal/operator/client_test.go index 99084f3..5353ef4 100644 --- a/apps/cli/internal/operator/client_test.go +++ b/apps/cli/internal/operator/client_test.go @@ -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 { diff --git a/apps/cli/internal/operator/output.go b/apps/cli/internal/operator/output.go index 1da0e08..ec2adfc 100644 --- a/apps/cli/internal/operator/output.go +++ b/apps/cli/internal/operator/output.go @@ -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) } diff --git a/apps/cli/internal/operator/parser_map.go b/apps/cli/internal/operator/parser_map.go index b623465..e76c332 100644 --- a/apps/cli/internal/operator/parser_map.go +++ b/apps/cli/internal/operator/parser_map.go @@ -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{} }, } } diff --git a/apps/cli/internal/operator/parser_map_test.go b/apps/cli/internal/operator/parser_map_test.go index 1126af8..514ec00 100644 --- a/apps/cli/internal/operator/parser_map_test.go +++ b/apps/cli/internal/operator/parser_map_test.go @@ -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) diff --git a/apps/cli/internal/operator/runner.go b/apps/cli/internal/operator/runner.go index a1fa4c2..ddfb347 100644 --- a/apps/cli/internal/operator/runner.go +++ b/apps/cli/internal/operator/runner.go @@ -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 { diff --git a/apps/cli/internal/operator/runner_paper_test.go b/apps/cli/internal/operator/runner_paper_test.go new file mode 100644 index 0000000..6dd6e46 --- /dev/null +++ b/apps/cli/internal/operator/runner_paper_test.go @@ -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) + } +} diff --git a/apps/cli/internal/operator/scenario.go b/apps/cli/internal/operator/scenario.go index 3c1df68..89871a0 100644 --- a/apps/cli/internal/operator/scenario.go +++ b/apps/cli/internal/operator/scenario.go @@ -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) diff --git a/apps/cli/internal/operator/scenario_test.go b/apps/cli/internal/operator/scenario_test.go index 05519c7..435fdc1 100644 --- a/apps/cli/internal/operator/scenario_test.go +++ b/apps/cli/internal/operator/scenario_test.go @@ -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) + } + }) + } +} diff --git a/apps/cli/testdata/operator/expected/paper_trading_state.jsonl b/apps/cli/testdata/operator/expected/paper_trading_state.jsonl new file mode 100644 index 0000000..d0bacb1 --- /dev/null +++ b/apps/cli/testdata/operator/expected/paper_trading_state.jsonl @@ -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"} diff --git a/apps/cli/testdata/operator/paper_trading_state.yaml b/apps/cli/testdata/operator/paper_trading_state.yaml new file mode 100644 index 0000000..f52470b --- /dev/null +++ b/apps/cli/testdata/operator/paper_trading_state.yaml @@ -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 diff --git a/apps/client/lib/src/contracts/alt_contracts.dart b/apps/client/lib/src/contracts/alt_contracts.dart index 236c72e..af65254 100644 --- a/apps/client/lib/src/contracts/alt_contracts.dart +++ b/apps/client/lib/src/contracts/alt_contracts.dart @@ -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)> altParserMap() { @@ -25,5 +26,10 @@ Map)> 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, }; } diff --git a/apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart b/apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart new file mode 100644 index 0000000..3c252d1 --- /dev/null +++ b/apps/client/lib/src/generated/alt/v1/paper_trading.pb.dart @@ -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(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(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(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(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(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(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(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'); diff --git a/apps/client/lib/src/generated/alt/v1/paper_trading.pbenum.dart b/apps/client/lib/src/generated/alt/v1/paper_trading.pbenum.dart new file mode 100644 index 0000000..ca3307d --- /dev/null +++ b/apps/client/lib/src/generated/alt/v1/paper_trading.pbenum.dart @@ -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 diff --git a/apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart b/apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart new file mode 100644 index 0000000..976a07d --- /dev/null +++ b/apps/client/lib/src/generated/alt/v1/paper_trading.pbjson.dart @@ -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=='); diff --git a/apps/client/test/contracts/alt_contracts_test.dart b/apps/client/test/contracts/alt_contracts_test.dart index 75dbde8..2ceb557 100644 --- a/apps/client/test/contracts/alt_contracts_test.dart +++ b/apps/client/test/contracts/alt_contracts_test.dart @@ -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; diff --git a/bin/contracts-gen b/bin/contracts-gen index e10c29e..897887a 100755 --- a/bin/contracts-gen +++ b/bin/contracts-gen @@ -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" diff --git a/packages/contracts/gen/go/alt/v1/paper_trading.pb.go b/packages/contracts/gen/go/alt/v1/paper_trading.pb.go new file mode 100644 index 0000000..2d1f303 --- /dev/null +++ b/packages/contracts/gen/go/alt/v1/paper_trading.pb.go @@ -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 +} diff --git a/packages/contracts/proto/alt/v1/paper_trading.proto b/packages/contracts/proto/alt/v1/paper_trading.proto new file mode 100644 index 0000000..08523d7 --- /dev/null +++ b/packages/contracts/proto/alt/v1/paper_trading.proto @@ -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; +} diff --git a/services/api/internal/contracts/parser_map.go b/services/api/internal/contracts/parser_map.go index c802410..56fa0be 100644 --- a/services/api/internal/contracts/parser_map.go +++ b/services/api/internal/contracts/parser_map.go @@ -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{} }, } } diff --git a/services/api/internal/contracts/parser_map_test.go b/services/api/internal/contracts/parser_map_test.go index e883358..50ed9d2 100644 --- a/services/api/internal/contracts/parser_map_test.go +++ b/services/api/internal/contracts/parser_map_test.go @@ -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{}, } } diff --git a/services/api/internal/socket/backtest_test.go b/services/api/internal/socket/backtest_test.go index 53620ae..4fef4a2 100644 --- a/services/api/internal/socket/backtest_test.go +++ b/services/api/internal/socket/backtest_test.go @@ -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 diff --git a/services/api/internal/socket/handlers.go b/services/api/internal/socket/handlers.go index 7b75623..dcac691 100644 --- a/services/api/internal/socket/handlers.go +++ b/services/api/internal/socket/handlers.go @@ -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 { diff --git a/services/api/internal/socket/paper.go b/services/api/internal/socket/paper.go new file mode 100644 index 0000000..48ad94c --- /dev/null +++ b/services/api/internal/socket/paper.go @@ -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 +} diff --git a/services/api/internal/socket/paper_test.go b/services/api/internal/socket/paper_test.go new file mode 100644 index 0000000..5eb9f59 --- /dev/null +++ b/services/api/internal/socket/paper_test.go @@ -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") + } +} diff --git a/services/api/internal/socket/server_test.go b/services/api/internal/socket/server_test.go index efb2f3f..b64146a 100644 --- a/services/api/internal/socket/server_test.go +++ b/services/api/internal/socket/server_test.go @@ -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") + } } diff --git a/services/api/internal/workerclient/client.go b/services/api/internal/workerclient/client.go index 2f64d4f..85b5cc2 100644 --- a/services/api/internal/workerclient/client.go +++ b/services/api/internal/workerclient/client.go @@ -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) } diff --git a/services/api/internal/workerclient/client_test.go b/services/api/internal/workerclient/client_test.go index 9c82aac..c77b84f 100644 --- a/services/api/internal/workerclient/client_test.go +++ b/services/api/internal/workerclient/client_test.go @@ -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) { diff --git a/services/worker/cmd/alt-worker/main.go b/services/worker/cmd/alt-worker/main.go index 2a5d829..1896795 100644 --- a/services/worker/cmd/alt-worker/main.go +++ b/services/worker/cmd/alt-worker/main.go @@ -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, } } diff --git a/services/worker/cmd/alt-worker/main_test.go b/services/worker/cmd/alt-worker/main_test.go index 0c8c312..8b3cee8 100644 --- a/services/worker/cmd/alt-worker/main_test.go +++ b/services/worker/cmd/alt-worker/main_test.go @@ -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. diff --git a/services/worker/internal/contracts/parser_map.go b/services/worker/internal/contracts/parser_map.go index 74d740c..c0e8456 100644 --- a/services/worker/internal/contracts/parser_map.go +++ b/services/worker/internal/contracts/parser_map.go @@ -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{} }, } } diff --git a/services/worker/internal/contracts/parser_map_test.go b/services/worker/internal/contracts/parser_map_test.go index 02e790f..8855e33 100644 --- a/services/worker/internal/contracts/parser_map_test.go +++ b/services/worker/internal/contracts/parser_map_test.go @@ -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{}, } } diff --git a/services/worker/internal/papertrading/engine.go b/services/worker/internal/papertrading/engine.go new file mode 100644 index 0000000..47597f3 --- /dev/null +++ b/services/worker/internal/papertrading/engine.go @@ -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 +} diff --git a/services/worker/internal/papertrading/engine_test.go b/services/worker/internal/papertrading/engine_test.go new file mode 100644 index 0000000..93d06e6 --- /dev/null +++ b/services/worker/internal/papertrading/engine_test.go @@ -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) + } +} diff --git a/services/worker/internal/papertrading/service.go b/services/worker/internal/papertrading/service.go new file mode 100644 index 0000000..7f2f553 --- /dev/null +++ b/services/worker/internal/papertrading/service.go @@ -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, + } +} diff --git a/services/worker/internal/papertrading/service_test.go b/services/worker/internal/papertrading/service_test.go new file mode 100644 index 0000000..572e02c --- /dev/null +++ b/services/worker/internal/papertrading/service_test.go @@ -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") + } +} diff --git a/services/worker/internal/socket/backtest.go b/services/worker/internal/socket/backtest.go index a89f567..5c92833 100644 --- a/services/worker/internal/socket/backtest.go +++ b/services/worker/internal/socket/backtest.go @@ -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 diff --git a/services/worker/internal/socket/handlers.go b/services/worker/internal/socket/handlers.go index 500d326..dfb2d23 100644 --- a/services/worker/internal/socket/handlers.go +++ b/services/worker/internal/socket/handlers.go @@ -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 } diff --git a/services/worker/internal/socket/paper.go b/services/worker/internal/socket/paper.go new file mode 100644 index 0000000..b7327c4 --- /dev/null +++ b/services/worker/internal/socket/paper.go @@ -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()) + } +} diff --git a/services/worker/internal/socket/paper_mapping.go b/services/worker/internal/socket/paper_mapping.go new file mode 100644 index 0000000..b61f449 --- /dev/null +++ b/services/worker/internal/socket/paper_mapping.go @@ -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 +} diff --git a/services/worker/internal/socket/paper_test.go b/services/worker/internal/socket/paper_test.go new file mode 100644 index 0000000..ab30620 --- /dev/null +++ b/services/worker/internal/socket/paper_test.go @@ -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()) + } +}