feat(live-trading): 계좌 동기화와 감사 추적을 추가한다

Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
This commit is contained in:
toki 2026-06-08 11:18:41 +09:00
parent c23fea2342
commit c0db4b24c1
65 changed files with 5942 additions and 714 deletions

View file

@ -22,12 +22,12 @@
- [완료] US Market Expansion
- 경로: `agent-roadmap/archive/phase/trading-expansion/milestones/us-market-expansion.md`
- 요약: 한국장 MVP 구조 위에 미국장 데이터를 추가한다.
- [검토중] Live Trading Boundary
- 경로: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- 요약: 실거래 adapter, 주문, 리스크, 포트폴리오 경계를 paper trading 안정화 이후에 연다.
- [진행중] 워크스페이스 포트/환경 표준화
- 경로: `agent-roadmap/phase/trading-expansion/milestones/workspace-port-env-standardization.md`
- 요약: ALT의 Flutter preview, API/worker, PostgreSQL/Redis host publish, 원격 테스트 환경 포트를 workspace 공통 대역으로 정렬한다.
- [계획] Live Trading Boundary
- 경로: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- 요약: 실거래 adapter, 주문, 리스크, 포트폴리오 경계를 paper trading 안정화 이후에 연다.
## Phase 경계

View file

@ -11,7 +11,7 @@
## 상태
[계획]
[검토중]
## 구현 잠금
@ -37,21 +37,23 @@
실거래 adapter, 주문 lifecycle, risk guard, account sync, audit trail 경계를 묶는다.
- [ ] [broker-port] broker adapter port가 paper/live 공통 경계를 갖는다. 검증: paper trading과 live trading 경계가 명확히 분리되고, live adapter 없이도 domain/contract 테스트가 가능하다.
- [ ] [order-lifecycle] 주문 생성, 제출, 체결, 취소, 실패 lifecycle이 정의된다. 검증: 실거래 주문 제출은 operator confirmation을 통과해야 한다.
- [ ] [risk-limits] 실거래 전 최소 risk limit과 kill switch가 있다. 검증: 실거래 주문 제출은 risk guard를 통과해야 한다.
- [ ] [account-sync] 계좌/포지션 동기화 경계가 있다.
- [ ] [audit-trail] live operation audit trail이 있다.
- [x] [broker-port] broker adapter port가 paper/live 공통 경계를 갖는다. 검증: paper trading과 live trading 경계가 명확히 분리되고, live adapter 없이도 domain/contract 테스트가 가능하다.
- [x] [order-lifecycle] 주문 생성, 제출, 체결, 취소, 실패 lifecycle이 정의된다. 검증: 실거래 주문 제출은 operator confirmation을 통과해야 한다.
- [x] [risk-limits] 실거래 전 최소 risk limit과 kill switch가 있다. 검증: 실거래 주문 제출은 risk guard를 통과해야 한다.
- [x] [account-sync] 계좌/포지션 동기화 경계가 있다.
- [x] [audit-trail] live operation audit trail이 있다.
## 완료 리뷰
- 상태: 없음
- 요청일: 없음
- 완료 근거: 아직 기능 Task가 충족되지 않았다.
- 상태: 요청됨
- 요청일: 2026-06-08
- 완료 근거: `broker-port`, `order-lifecycle`, `risk-limits`, `account-sync`, `audit-trail`이 모두 PASS 완료 로그로 충족됐다.
- 완료 근거: `account-sync``agent-task/archive/2026/06/m-live-trading-boundary/04+01_account_sync/complete.log`의 Roadmap Completion으로 확인했다.
- 완료 근거: `audit-trail``agent-task/archive/2026/06/m-live-trading-boundary/05+02,03,04_audit_trail/complete.log`의 Roadmap Completion으로 확인했다.
- 리뷰 필요:
- [ ] 사용자가 완료 결과를 확인했다
- [ ] archive 이동을 승인했다
- 리뷰 코멘트: 없음
- 리뷰 코멘트: 모든 기능 Task가 완료되어 Milestone을 `[검토중]`으로 전환했다. 사용자 최종 확인 후 `[완료]` 전환과 archive 이동을 진행한다.
## 범위 제외
@ -67,3 +69,4 @@
- 선행 작업: Paper Trading Readiness, Paper Trading Command Workflow, US Market Expansion
- 후속 작업: Flutter Operator Console MVP
- 확인 필요: 없음
- 현재 작업 현황: 모든 기능 Task 완료. Milestone 완료 리뷰 요청됨, 사용자 최종 확인과 archive 승인 대기.

View file

@ -0,0 +1,259 @@
<!-- task=m-live-trading-boundary/04+01_account_sync plan=0 tag=LIVE_ACCOUNT -->
# Code Review Reference - LIVE_ACCOUNT
> **[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-07
task=m-live-trading-boundary/04+01_account_sync, plan=0, tag=LIVE_ACCOUNT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `account-sync`: 계좌/포지션 동기화 경계가 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/04+01_account_sync/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [LIVE_ACCOUNT-1] account snapshot domain/contract를 확장한다. | [x] |
| [LIVE_ACCOUNT-2] worker broker account sync service와 socket handlers를 추가한다. | [x] |
| [LIVE_ACCOUNT-3] API forwarder와 CLI headless account sync workflow를 추가한다. | [x] |
| [LIVE_ACCOUNT-4] focused verification과 affected module tests를 실행한다. | [x] |
## 구현 체크리스트
- [x] [LIVE_ACCOUNT-1] account snapshot domain/contract를 확장한다.
- [x] [LIVE_ACCOUNT-2] worker broker account sync service와 socket handlers를 추가한다.
- [x] [LIVE_ACCOUNT-3] API forwarder와 CLI headless account sync workflow를 추가한다.
- [x] [LIVE_ACCOUNT-4] focused verification과 affected module tests를 실행한다.
- [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_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G05_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-live-trading-boundary/04+01_account_sync/`를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/04+01_account_sync/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-live-trading-boundary/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
- **`currencyToProto` 중복 제거**: `live_mapping.go`에 `currencyToProto`를 추가하려 했으나 `backtest_mapping.go`에 동일 패키지 내 함수가 이미 존재함을 발견. 중복 선언 대신 기존 함수를 재사용. 계획 문서에는 언급이 없었으나 Go 컴파일러 오류로 파악하여 즉시 해소.
- **`account.go` 신규 파일 생성**: 계획에서는 `types.go` 내 인라인 헬퍼를 기대했으나, `SortCashBalances`, `SortPositionSnapshots`, `HasRawAccountNumber` 헬퍼는 분리된 `account.go`에 배치. `types.go`는 타입 선언에만 집중하는 기존 패턴 유지.
- **`types_test.go` 수정**: `AccountSnapshot` 구조 변경(단일 `Cash market.Decimal` → `[]CashBalance`)으로 인해 기존 `TestAccountSnapshotRequiresNoFloatMath`가 컴파일 오류. 기존 필드 제거 후 새 구조에 맞게 재작성. 계획 범위에 해당.
## 주요 설계 결정
- **Worker-owned in-memory snapshot map**: Account snapshot은 `Service` 내 `map[string]*trading.AccountSnapshot`에 보관. `snapshotMu sync.RWMutex`로 concurrent read/write를 직렬화. Durable storage는 이 subtask 범위 밖이며, 스냅샷은 프로세스 재시작 시 소멸됨이 의도된 설계.
- **BrokerPort 경계에서 alias 보장**: `FetchAccountSnapshot` 인터페이스 계약 주석에 "raw account number와 provider payload는 어댑터 내부에서 제거하고, AccountID는 operator alias/internal id여야 한다"를 명시. Service 레이어는 신뢰하고 수신.
- **`liveAccountErrorCode` 헬퍼 분리**: worker socket `live.go`에서 `ErrBrokerUnavailable`→`unavailable`, `ErrAccountSnapshotNotFound`→`not_found`를 매핑하는 단일 헬퍼를 추가. 오류 코드 드리프트를 방지.
- **API는 thin forwarder**: API socket handler는 `account_id` 비어있음 검증만 수행하고 나머지는 worker에 위임. 응답 nil 체크와 connect 실패 매핑만 추가.
- **CLI output key 설계**: `live_account_id`, `live_broker`, `cash_count`, `live_position_count`, `synced_at_unix_ms`, `live_stale` 6개 키. 금액 값은 노출하지 않음(Flutter UI 범위). `headless_validation.md`와 `expected/live_account_sync.jsonl` fixture에 반영.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `01_broker_port` completion이 prerequisite로 충족됐는지 확인한다.
- Raw account number, provider raw payload, secret이 tracked fixtures/output에 들어오지 않았는지 확인한다.
- Account snapshot이 worker-owned broker port를 통해 들어오고 API는 thin forwarder인지 확인한다.
- Durable storage가 이 subtask에 섞이지 않았는지 확인한다.
- CLI output keys가 headless validation matrix와 expected JSONL에 반영됐는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### LIVE_ACCOUNT-1 중간 검증
```text
$ bin/contracts-gen
(no output — success)
$ bin/contracts-check
(no output — success)
$ go test ./packages/domain/... ./packages/contracts/gen/go/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
```
### LIVE_ACCOUNT-2 중간 검증
```text
$ go test ./services/worker/internal/livetrading ./services/worker/internal/socket
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.063s
```
### LIVE_ACCOUNT-3 중간 검증
```text
$ go test ./services/api/internal/socket ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/api/internal/socket 0.019s
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.190s
```
### 최종 검증
```text
$ bin/contracts-check
(no output — success)
$ go test ./packages/domain/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading (cached)
$ go test ./packages/contracts/gen/go/...
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
$ go test ./services/worker/...
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 (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
? 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 (cached)
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ go test ./services/api/...
? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files]
ok git.toki-labs.com/toki/alt/services/api/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/services/api/internal/socket (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/workerclient (cached)
$ go test ./apps/cli/...
? 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.168s
$ bin/lint
(Flutter info-only: 13 avoid_print warnings in mattermost_auth_service.dart — pre-existing, not introduced by this change)
```
---
> **[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 | Pass | worker/API/CLI sync/get flow 자체는 focused rerun에서 컴파일 및 단위 테스트 통과. |
| Completeness | Fail | 새 account sync runtime contract가 모든 parser map과 parser coverage에 반영되지 않았다. |
| Test coverage | Fail | 기존 parser map tests가 새 account sync 메시지 누락을 잡지 못하고 통과한다. |
| API contract | Fail | client-facing protobuf surface를 추가했지만 Flutter client parser map이 갱신되지 않았고 Go parser maps도 nested account position 메시지가 빠졌다. |
| Code quality | Pass | review 중 `gofmt -l`이 잡은 formatting drift는 `gofmt -w`로 직접 정리했고 재확인했다. |
| Plan deviation | Fail | 계획의 parser map/account message 등록 및 contracts rule의 client parser map 갱신 요구를 충족하지 못했다. |
| Verification trust | Pass | 구현자가 기록한 핵심 Go/lint 검증은 reviewer rerun과 일치한다. 다만 통과한 테스트가 coverage gap을 드러냈다. |
### 발견된 문제
- Required: `apps/client/lib/src/contracts/alt_contracts.dart:93`에서 Flutter `altParserMap()`이 `LiveRiskDecision` 이후 종료되어 `SyncLiveAccountRequest`, `SyncLiveAccountResponse`, `GetLiveAccountSnapshotRequest`, `GetLiveAccountSnapshotResponse`, `LiveAccountSnapshot`, `LiveCashBalance`, `LivePositionSnapshot` parser를 전혀 등록하지 않는다. 이번 변경은 API client-facing runtime surface라 Flutter proto-socket client도 이 메시지를 decode할 수 있어야 한다. 해당 parser entries를 추가하고 [apps/client/test/contracts/alt_contracts_test.dart](/config/workspace/alt/apps/client/test/contracts/alt_contracts_test.dart:41)의 `expectedMessages`와 parser count를 새 메시지까지 포함하도록 갱신한 뒤 `cd apps/client && flutter test test/contracts/alt_contracts_test.dart`를 실행한다.
- Required: [services/api/internal/contracts/parser_map.go](/config/workspace/alt/services/api/internal/contracts/parser_map.go:80), [services/worker/internal/contracts/parser_map.go](/config/workspace/alt/services/worker/internal/contracts/parser_map.go:76), [apps/cli/internal/operator/parser_map.go](/config/workspace/alt/apps/cli/internal/operator/parser_map.go:82)가 `LiveAccountSnapshot`/`LiveCashBalance`는 등록하지만 `LivePositionSnapshot`을 누락한다. 또한 [services/api/internal/contracts/parser_map_test.go](/config/workspace/alt/services/api/internal/contracts/parser_map_test.go:16), [services/worker/internal/contracts/parser_map_test.go](/config/workspace/alt/services/worker/internal/contracts/parser_map_test.go:12), [apps/cli/internal/operator/parser_map_test.go](/config/workspace/alt/apps/cli/internal/operator/parser_map_test.go:44)의 독립 coverage 목록이 새 account sync 메시지를 요구하지 않아 누락이 테스트에서 검출되지 않는다. 세 Go parser maps에 `LivePositionSnapshot`을 추가하고 각 parser map test가 account sync request/response/snapshot/cash/position 메시지를 모두 요구하도록 보강한다.
### 다음 단계
FAIL follow-up: 새 `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`에서 parser map completeness와 parser coverage만 좁게 보완한다. `USER_REVIEW.md`는 생성하지 않는다.
### Reviewer 검증
```text
$ bin/contracts-check
(no output, exit code 0)
$ go test ./services/api/internal/contracts ./services/worker/internal/contracts ./services/api/internal/socket ./services/worker/internal/livetrading ./services/worker/internal/socket ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/api/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/socket (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +1: All tests passed!
$ go test ./packages/domain/... ./packages/contracts/gen/go/... ./services/worker/... ./services/api/... ./apps/cli/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading (cached)
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/socket (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ bin/lint
13 pre-existing Flutter avoid_print info entries in mattermost_auth_service.dart; exit code 0
$ gofmt -l <changed Go files>
apps/cli/internal/operator/client_test.go
apps/cli/internal/operator/output.go
apps/cli/internal/operator/scenario.go
services/worker/internal/socket/live_mapping.go
services/worker/internal/socket/live_test.go
$ gofmt -w apps/cli/internal/operator/client_test.go apps/cli/internal/operator/output.go apps/cli/internal/operator/scenario.go services/worker/internal/socket/live_mapping.go services/worker/internal/socket/live_test.go
$ gofmt -l <same files>
(no output, exit code 0)
$ go test ./services/worker/internal/socket ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.063s
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
```

View file

@ -0,0 +1,220 @@
<!-- task=m-live-trading-boundary/04+01_account_sync plan=1 tag=REVIEW_LIVE_ACCOUNT -->
# Code Review Reference - REVIEW_LIVE_ACCOUNT
> **[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-08
task=m-live-trading-boundary/04+01_account_sync, plan=1, tag=REVIEW_LIVE_ACCOUNT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `account-sync`: 계좌/포지션 동기화 경계가 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/04+01_account_sync/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_LIVE_ACCOUNT-1] Flutter client parser map과 contract parser test가 account sync 메시지를 모두 포함한다. | [x] |
| [REVIEW_LIVE_ACCOUNT-2] Go API/worker/CLI parser maps와 parser coverage가 account sync 메시지를 모두 포함한다. | [x] |
## 구현 체크리스트
- [x] [REVIEW_LIVE_ACCOUNT-1] Flutter client parser map과 contract parser test가 account sync 메시지를 모두 포함한다.
- [x] [REVIEW_LIVE_ACCOUNT-2] Go API/worker/CLI parser maps와 parser coverage가 account sync 메시지를 모두 포함한다.
- [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_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_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-live-trading-boundary/04+01_account_sync/`를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/04+01_account_sync/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-live-trading-boundary/`를 제거하거나, 남은 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 parser map 항목 순서는 기존 live trading 항목 직후에 account sync 7개를 추가했다. `LivePositionSnapshot`을 마지막에 배치했으나 `LiveAccountSnapshot`이 이를 참조하는 순서와 무관하게 parser map은 keyed map이므로 순서 의존성 없음.
- Go 3개 parser map 모두 `LiveCashBalance` 직후에 `LivePositionSnapshot`을 추가해 live account sync 블록 내 일관성 유지.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Flutter `altParserMap()`에 account sync request/response/snapshot/cash/position 메시지가 모두 등록됐는지 확인한다.
- Go API/worker/CLI parser maps에 `LivePositionSnapshot`이 추가됐고, tests가 source factory list와 독립적으로 새 메시지를 요구하는지 확인한다.
- protobuf schema/generated outputs나 worker/API/CLI account sync behavior를 불필요하게 바꾸지 않았는지 확인한다.
- `bin/lint`의 Flutter `avoid_print` info는 pre-existing인지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_LIVE_ACCOUNT-1 중간 검증
```text
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +0: loading /config/workspace/alt/apps/client/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!
```
### REVIEW_LIVE_ACCOUNT-2 중간 검증
```text
$ go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.185s
```
### 최종 검증
```text
$ bin/contracts-check
(exit code 0, no output)
$ go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/api/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts 0.004s
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.185s
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +0: loading /config/workspace/alt/apps/client/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!
$ bin/lint
Analyzing client...
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:27:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:35:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:41:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:44:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:51:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:55:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:101:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:123:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:132:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:134:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:137:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:154:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:156:7 • avoid_print
13 issues found. (ran in 6.6s)
```
계획 대비 변경 사항: `mattermost_auth_service.dart`의 `avoid_print` info 13건은 계획에 명시된 pre-existing lint info다.
---
> **[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.
## 코드리뷰 결과
### 종합 판정
PASS
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Pass | Flutter/Go parser maps에 account sync request/response/snapshot/cash/position 메시지가 모두 등록됐다. |
| Completeness | Pass | `REVIEW_LIVE_ACCOUNT-1`, `REVIEW_LIVE_ACCOUNT-2`, review stub 작성 항목이 모두 충족됐다. |
| Test coverage | Pass | Flutter contract parser test와 Go API/worker/CLI parser map tests가 새 메시지를 독립 expected list에 포함한다. |
| API contract | Pass | client-facing runtime contract가 API/worker/CLI/Flutter parser boundaries에 반영됐다. |
| Code quality | Pass | scope가 parser registration/test 보강에 한정되고 `gofmt -l`, `git diff --check`가 깨끗하다. |
| Plan deviation | Pass | 후속 plan의 수정 파일과 범위를 그대로 이행했다. |
| Verification trust | Pass | 기록된 검증 명령을 reviewer가 재실행했고 모두 exit code 0이다. `bin/lint`의 13개 info는 pre-existing `avoid_print`로 계획에 명시된 예외다. |
### 발견된 문제
없음
### 다음 단계
PASS: `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-live-trading-boundary/04+01_account_sync/`로 이동한다. `m-live-trading-boundary` completion event metadata를 보고하고 roadmap 수정은 수행하지 않는다.
### Reviewer 검증
```text
$ bin/contracts-check
(no output, exit code 0)
$ go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/api/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +0: loading /config/workspace/alt/apps/client/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!
$ bin/lint
13 pre-existing Flutter avoid_print info entries in mattermost_auth_service.dart; exit code 0
$ git diff --check
(no output, exit code 0)
$ gofmt -l services/api/internal/contracts/parser_map.go services/api/internal/contracts/parser_map_test.go services/worker/internal/contracts/parser_map.go services/worker/internal/contracts/parser_map_test.go apps/cli/internal/operator/parser_map.go apps/cli/internal/operator/parser_map_test.go apps/cli/internal/operator/client_test.go apps/cli/internal/operator/output.go apps/cli/internal/operator/scenario.go services/worker/internal/socket/live_mapping.go services/worker/internal/socket/live_test.go
(no output, exit code 0)
```

View file

@ -0,0 +1,47 @@
# Complete - m-live-trading-boundary/04+01_account_sync
## 완료 일시
2026-06-08
## 요약
Live Trading Boundary의 `account-sync` subtask를 2회 리뷰 루프 끝에 PASS로 종료했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G05_0.log` | `code_review_cloud_G05_0.log` | FAIL | Flutter client parser map, Go parser map `LivePositionSnapshot`, parser coverage 보강 필요 |
| `plan_local_G06_1.log` | `code_review_local_G06_1.log` | PASS | 모든 Required/Suggested 항목 해소 |
## 구현/정리 내용
- Live account sync domain/contract, worker service/socket, API forwarder, CLI headless workflow를 추가했다.
- Account sync protobuf messages를 API/worker/CLI parser maps와 Flutter client parser map에 등록했다.
- Go/Dart parser map tests가 account sync request/response/snapshot/cash/position messages를 독립 expected list로 검증하도록 보강했다.
- 리뷰 중 active review artifact의 불필요한 closing fence를 정리했다.
## 최종 검증
- `bin/contracts-check` - PASS; no output, exit code 0
- `go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator` - PASS; API contracts, worker contracts, CLI operator packages passed
- `cd apps/client && flutter test test/contracts/alt_contracts_test.dart` - PASS; ALT parser map contract test passed
- `bin/lint` - PASS; exit code 0 with 13 pre-existing `avoid_print` analyzer info entries in `mattermost_auth_service.dart`
- `git diff --check` - PASS; no output
- `gofmt -l services/api/internal/contracts/parser_map.go services/api/internal/contracts/parser_map_test.go services/worker/internal/contracts/parser_map.go services/worker/internal/contracts/parser_map_test.go apps/cli/internal/operator/parser_map.go apps/cli/internal/operator/parser_map_test.go apps/cli/internal/operator/client_test.go apps/cli/internal/operator/output.go apps/cli/internal/operator/scenario.go services/worker/internal/socket/live_mapping.go services/worker/internal/socket/live_test.go` - PASS; no output
## Roadmap Completion
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Completed task ids:
- `account-sync`: PASS; evidence=`plan_local_G06_1.log`, `code_review_local_G06_1.log`; verification=`bin/contracts-check`, `go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator`, `cd apps/client && flutter test test/contracts/alt_contracts_test.dart`, `bin/lint`, `git diff --check`, `gofmt -l ...`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,122 @@
<!-- task=m-live-trading-boundary/04+01_account_sync plan=1 tag=REVIEW_LIVE_ACCOUNT -->
# Plan - REVIEW_LIVE_ACCOUNT
## 이 파일을 읽는 구현 에이전트에게
이 계획은 이전 리뷰 `code_review_cloud_G05_0.log`의 Required 항목만 해소한다. 구현 후 검증을 실행하고 실제 변경 내용, 설계 결정, 명령 출력(stdout/stderr)을 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 기록한 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review 전용이다.
구현 중 사용자만 결정할 수 있는 사항, 사용자 소유 외부 환경/secret, 또는 계획 범위 충돌 없이는 안전하게 진행할 수 없으면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 근거, 실행 명령/출력, 재개 조건을 기록하고 중단한다. 구현 에이전트는 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `account-sync`: 계좌/포지션 동기화 경계가 있다.
- Completion mode: check-on-pass
## 배경
첫 리뷰는 account sync worker/API/CLI 흐름 자체보다 contract parser completeness에서 FAIL했다. 새 protobuf account sync surface는 client-facing runtime surface이므로 Go API/worker/CLI parser maps와 Flutter client parser map/test가 모두 새 메시지를 decode할 수 있어야 한다.
## 이전 리뷰 요약
- `code_review_cloud_G05_0.log`
- Required: Flutter `altParserMap()`이 새 account sync 메시지를 등록하지 않는다.
- Required: Go API/worker/CLI parser maps가 `LivePositionSnapshot`을 누락하고 parser map tests가 새 account sync 메시지를 요구하지 않는다.
## 범위 결정 근거
이 follow-up은 parser registration과 parser coverage만 수정한다. protobuf schema, generated output, worker service behavior, API forwarding behavior, CLI scenario output semantics는 변경하지 않는다.
## 빌드 등급
Build lane `local-G06`, review lane `local-G06`. 실패 원인이 명확하고 deterministic parser/test 보강으로 한정된다.
## 구현 체크리스트
- [ ] [REVIEW_LIVE_ACCOUNT-1] Flutter client parser map과 contract parser test가 account sync 메시지를 모두 포함한다.
- [ ] [REVIEW_LIVE_ACCOUNT-2] Go API/worker/CLI parser maps와 parser coverage가 account sync 메시지를 모두 포함한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_LIVE_ACCOUNT-1] Flutter Parser Coverage
#### 문제
`apps/client/lib/src/contracts/alt_contracts.dart`는 generated Dart account sync messages가 생겼는데도 `altParserMap()`에 `SyncLiveAccountRequest`, `SyncLiveAccountResponse`, `GetLiveAccountSnapshotRequest`, `GetLiveAccountSnapshotResponse`, `LiveAccountSnapshot`, `LiveCashBalance`, `LivePositionSnapshot`을 등록하지 않는다. `apps/client/test/contracts/alt_contracts_test.dart`도 expected list/count가 stale이라 누락을 잡지 못한다.
#### 해결 방법
Flutter parser map에 account sync messages를 추가하고, contract parser test의 expected list와 parser count를 새 메시지 수에 맞게 갱신한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/client/lib/src/contracts/alt_contracts.dart`에 account sync parser entries를 추가한다.
- [ ] `apps/client/test/contracts/alt_contracts_test.dart`의 expected messages와 count를 갱신한다.
#### 테스트 작성
작성한다. 기존 round-trip parser test가 새 메시지 전체를 포함하도록 보강한다.
#### 중간 검증
```bash
cd apps/client && flutter test test/contracts/alt_contracts_test.dart
```
기대 결과: exit code 0.
### [REVIEW_LIVE_ACCOUNT-2] Go Parser Coverage
#### 문제
Go parser maps는 `LiveAccountSnapshot`과 `LiveCashBalance`를 등록했지만 `LivePositionSnapshot`을 빠뜨렸다. 또한 독립 coverage tests가 account sync request/response/snapshot/cash/position messages를 요구하지 않아 누락이 통과된다.
#### 해결 방법
API/worker/CLI parser maps에 `LivePositionSnapshot`을 추가한다. API/worker contracts parser tests와 CLI parser map test에 account sync request/response, `LiveAccountSnapshot`, `LiveCashBalance`, `LivePositionSnapshot`을 모두 추가한다.
#### 수정 파일 및 체크리스트
- [ ] `services/api/internal/contracts/parser_map.go`에 `LivePositionSnapshot` parser를 추가한다.
- [ ] `services/worker/internal/contracts/parser_map.go`에 `LivePositionSnapshot` parser를 추가한다.
- [ ] `apps/cli/internal/operator/parser_map.go`에 `LivePositionSnapshot` parser를 추가한다.
- [ ] `services/api/internal/contracts/parser_map_test.go`의 independent required list를 account sync messages로 갱신한다.
- [ ] `services/worker/internal/contracts/parser_map_test.go`의 independent required list를 account sync messages로 갱신한다.
- [ ] `apps/cli/internal/operator/parser_map_test.go`의 independent required list를 account sync messages로 갱신한다.
#### 테스트 작성
작성한다. Parser map tests가 누락 시 실패하도록 source factory list와 독립된 expected list에 새 메시지를 명시한다.
#### 중간 검증
```bash
go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator
```
기대 결과: exit code 0.
## 최종 검증
```bash
bin/contracts-check
go test ./services/api/internal/contracts ./services/worker/internal/contracts ./apps/cli/internal/operator
cd apps/client && flutter test test/contracts/alt_contracts_test.dart
bin/lint
```
기대 결과: 모든 명령 exit code 0. `bin/lint`가 기존 `mattermost_auth_service.dart`의 `avoid_print` info만 출력한다면 계획 대비 변경 사항에 pre-existing lint info로 기록한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/client/lib/src/contracts/alt_contracts.dart` | REVIEW_LIVE_ACCOUNT-1 |
| `apps/client/test/contracts/alt_contracts_test.dart` | REVIEW_LIVE_ACCOUNT-1 |
| `services/api/internal/contracts/parser_map.go` | REVIEW_LIVE_ACCOUNT-2 |
| `services/api/internal/contracts/parser_map_test.go` | REVIEW_LIVE_ACCOUNT-2 |
| `services/worker/internal/contracts/parser_map.go` | REVIEW_LIVE_ACCOUNT-2 |
| `services/worker/internal/contracts/parser_map_test.go` | REVIEW_LIVE_ACCOUNT-2 |
| `apps/cli/internal/operator/parser_map.go` | REVIEW_LIVE_ACCOUNT-2 |
| `apps/cli/internal/operator/parser_map_test.go` | REVIEW_LIVE_ACCOUNT-2 |

View file

@ -0,0 +1,346 @@
<!-- task=m-live-trading-boundary/05+02,03,04_audit_trail plan=0 tag=LIVE_AUDIT -->
# Code Review Reference - LIVE_AUDIT
> **[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-07
task=m-live-trading-boundary/05+02,03,04_audit_trail, plan=0, tag=LIVE_AUDIT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `audit-trail`: live operation audit trail이 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/05+02,03,04_audit_trail/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [LIVE_AUDIT-1] live audit domain/contract event shape를 확장한다. | [x] |
| [LIVE_AUDIT-2] storage port, migration, sqlc query, postgres store를 추가한다. | [x] |
| [LIVE_AUDIT-3] worker live operations에 audit writer와 query handler를 연결한다. | [x] |
| [LIVE_AUDIT-4] API/CLI audit query workflow와 fixtures를 추가한다. | [x] |
| [LIVE_AUDIT-5] storage/generated/focused verification을 실행한다. | [x] |
## 구현 체크리스트
- [x] [LIVE_AUDIT-1] live audit domain/contract event shape를 확장한다.
- [x] [LIVE_AUDIT-2] storage port, migration, sqlc query, postgres store를 추가한다.
- [x] [LIVE_AUDIT-3] worker live operations에 audit writer와 query handler를 연결한다.
- [x] [LIVE_AUDIT-4] API/CLI audit query workflow와 fixtures를 추가한다.
- [x] [LIVE_AUDIT-5] storage/generated/focused verification을 실행한다.
- [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이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-live-trading-boundary/05+02,03,04_audit_trail/`를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/05+02,03,04_audit_trail/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-live-trading-boundary/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
1. **fixture 파일명**: 계획은 `live_audit_trail.yaml` / `live_audit_trail.jsonl`을 명시했으나 실제 구현에서는 `live_audit_query.yaml` / `live_audit_query.jsonl`로 작성했다. 파일명이 action(`list_live_audit_events`)과 의미 정합성이 더 높다.
2. **`recordRiskRejectedOrder` 시그니처 변경**: audit event 방출을 위해 `ctx context.Context`를 첫 번째 매개변수로 추가했다. 계획에는 명시되지 않았으나 `SubmitLiveOrder` 흐름 내부에서만 호출되는 private helper이므로 API 파괴 범위는 없다.
3. **`SetKillSwitch` ctx 처리**: `SetKillSwitch(ks KillSwitchState) KillSwitchState` 시그니처는 ctx/error를 갖지 않는다. 시그니처 변경은 14개 이상의 테스트 수정을 유발하므로, 내부에서 `context.Background()`를 사용하고 audit 실패를 무시(`_ = s.appendAudit(...)`)했다. Kill switch는 안전 장치이므로 audit 실패가 동작을 막아선 안 된다.
4. **`AuditStore`가 worker `livetrading.Service`에 직접 정의**: `storage.LiveAuditStore`를 직접 주입하지 않고 `livetrading` 패키지 내 좁은 `AuditStore` 인터페이스를 정의했다. 계획의 `storage.LiveAuditStore`와 시그니처는 동일하나 패키지 분리로 순환 import를 방지한다.
5. **`audit_event_count` 출력 필드명**: 계획은 `audit_count`, `audit_event_id`, `audit_event_type` 등 다수 필드를 언급했으나, CLI output는 `audit_event_count` 단일 집계 필드만 렌더링한다. 개별 이벤트 상세 필드는 향후 Flutter operator console에서 처리할 범위다.
## 주요 설계 결정
1. **Mandatory vs. best-effort audit 분리**:
- 성공 경로(`submit_confirmed`, `broker_rejected`, `cancel_confirmed`, `account_sync_success`): audit append 실패 시 `ErrAuditFailed` 반환 (mandatory).
- 실패 경로(`account_sync_failure`): audit append 실패를 무시 (best-effort). 브로커 오류가 audit 오류로 가려지는 것을 방지한다.
- Kill switch: `context.Background()` + audit 무시. 안전 장치 우선 원칙.
2. **Broker 필드 처리**: `livetrading.Service`는 order 작업 시 broker identity를 직접 알지 못한다 (snapshots를 통해서만 노출). Order operation audit events는 `Broker` 필드를 빈 문자열로 둔다; account sync events만 `snap.Broker`로 채운다.
3. **nil AuditStore 허용**: `auditStore == nil`이면 audit event는 무음으로 드롭된다 (`appendAudit`에서 early return). `ListLiveAuditEvents`는 `nil, nil`을 반환한다. Socket handler는 nil events를 빈 slice로 변환한다. 이로써 AuditStore가 미설정된 headless 시나리오가 정상 동작한다.
4. **`newAuditEventID()` 구현**: `fmt.Sprintf("aud-%d", time.Now().UnixNano())`. 단조 증가 + 가독성 prefix. UUID 대신 UnixNano를 쓴 이유는 ordering이 자연스럽고 외부 의존이 없기 때문이다.
5. **Payload 타입**: `map[string]string`. proto `live_trading.proto:payload`는 `map<string, string>`으로 정의. raw provider body, token, account number를 저장하지 않는다. `mapping_test.go`에서 금지 키(`raw_body`, `token`, `password`, `secret`) 방어 테스트를 추가했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `02+01_order_lifecycle`, `03+01,02_risk_kill_switch`, `04+01_account_sync` completion이 prerequisite로 충족됐는지 확인한다.
- Migration 000004가 새 파일로 추가됐고 기존 migration을 수정하지 않았는지 확인한다.
- `bin/worker-storage-check`와 sqlc generated output이 일치하는지 확인한다.
- Audit payload에 raw token, raw account number, raw provider response가 저장되지 않는지 확인한다.
- Worker operation이 audit append 실패를 무시하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### LIVE_AUDIT-1 중간 검증
```text
$ bin/contracts-gen
(contracts-gen은 proto 파일 재생성 스크립트. 이미 커밋된 generated output이 proto와 일치하므로 재생성 후 diff 없음.)
$ bin/contracts-check
(exit 0, no output)
$ go test ./packages/domain/... ./packages/contracts/gen/go/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
```
### LIVE_AUDIT-2 중간 검증
```text
$ bin/worker-storage-gen
Generating worker storage code via sqlc...
Generation complete.
$ bin/worker-storage-check
Generating worker storage code via sqlc...
Generation complete.
$ go test ./services/worker/internal/storage/...
? 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
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
```
(postgres integration tests: DATABASE_URL 미설정으로 skip됨. 기존 store_test.go:27 기준과 동일.)
### LIVE_AUDIT-3 중간 검증
```text
$ go test ./services/worker/internal/livetrading ./services/worker/internal/socket
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading
ok git.toki-labs.com/toki/alt/services/worker/internal/socket
```
### LIVE_AUDIT-4 중간 검증
```text
$ go test ./services/api/internal/socket ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/api/internal/socket
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator
```
### 최종 검증
```text
$ bin/worker-storage-check
Generating worker storage code via sqlc...
Generation complete.
$ bin/contracts-check
(exit 0, no output)
$ go test ./packages/domain/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading (cached)
$ go test ./packages/contracts/gen/go/...
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
$ go test ./services/worker/...
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker (cached)
? 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 (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
? 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 (cached)
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ go test ./services/api/...
? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files]
ok git.toki-labs.com/toki/alt/services/api/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/socket (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/workerclient (cached)
$ go test ./apps/cli/...
? git.toki-labs.com/toki/alt/apps/cli/cmd/alt [no test files]
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ bin/lint
Analyzing client...
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:27:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:35:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:41:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:44:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:51:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:55:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:101:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:123:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:132:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:134:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:137:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:154:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:156:7 • avoid_print
13 issues found. (ran in 2.5s)
(모두 mattermost_auth_service.dart의 기존 avoid_print 경고. 이번 변경과 무관한 pre-existing issues.)
```
---
> **[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 | runtime worker deps에 live service가 없어 audit query surface가 실제 worker에서 unavailable이며, 성공 주문의 broker accepted audit event가 누락된다. |
| completeness | Fail | 계획의 live operation audit writer/runtime query wiring과 raw payload rejection 요구가 끝까지 충족되지 않았다. |
| test coverage | Fail | `storeBackedDeps` live wiring, `broker_accepted` emission, forbidden audit payload append rejection을 잡는 테스트가 없다. |
| API contract | Fail | API/CLI forwarding 코드는 있으나 worker bootstrap이 `Deps.Live`를 채우지 않아 runtime contract가 `unavailable`로만 노출된다. |
| code quality | Pass | 리뷰 중 gofmt drift 2건을 직접 정리했고, 남은 포맷/whitespace 문제는 없다. |
| plan deviation | Fail | `submit_confirmed`/`broker_accepted` 분리와 durable audit storage safety 요구가 구현과 어긋난다. |
| verification trust | Warn | 기록된 명령과 로컬 재실행은 통과했지만, 현재 테스트가 위 Required 동작을 커버하지 못한다. |
### 발견된 문제
- Required: [services/worker/cmd/alt-worker/main.go:96](/config/workspace/alt/services/worker/cmd/alt-worker/main.go:96) `storeBackedDeps`가 `socket.Deps.Live`를 설정하지 않는다. `handleListLiveAuditEvents`는 `deps.Live == nil`이면 즉시 `unavailable`을 반환하므로([services/worker/internal/socket/live.go:397](/config/workspace/alt/services/worker/internal/socket/live.go:397)), 이번에 추가한 API/CLI audit query workflow는 실제 worker process에서 동작하지 않는다. `livetrading.NewService(...)`를 worker deps에 배선하고 `SetAuditStore(store)`로 durable store를 주입하라. 현재 KIS broker port가 아직 없으면 `NewService(nil)`로 시작해 order/sync는 broker unavailable을 유지하되 `ListLiveAuditEvents`는 store-backed로 동작하게 하고, [services/worker/cmd/alt-worker/main_test.go:15](/config/workspace/alt/services/worker/cmd/alt-worker/main_test.go:15)에 `deps.Live != nil` 및 audit store delegation을 검증하는 테스트를 추가하라.
- Required: [services/worker/internal/livetrading/service.go:343](/config/workspace/alt/services/worker/internal/livetrading/service.go:343) 성공 submit 경로가 `submit_confirmed` 하나만 기록하고 `broker_accepted`를 절대 기록하지 않는다. 계획은 operator confirmation과 broker response를 재현 가능한 audit trail로 남기며 `Submit confirmation, broker accepted/rejected`를 명시했다. `SubmitLiveOrder`에서 operator confirmation event와 broker accepted/rejected event를 분리해 append하고, [services/worker/internal/livetrading/service_test.go:905](/config/workspace/alt/services/worker/internal/livetrading/service_test.go:905)의 성공 submit 테스트가 `submit_confirmed`와 `broker_accepted` 둘 다 검증하도록 보강하라.
- Required: [services/worker/internal/storage/postgres/store.go:220](/config/workspace/alt/services/worker/internal/storage/postgres/store.go:220) / [services/worker/internal/storage/postgres/mapping.go:335](/config/workspace/alt/services/worker/internal/storage/postgres/mapping.go:335) `AppendLiveAuditEvent`가 forbidden payload key를 거부하지 않고 그대로 JSONB로 저장한다. [services/worker/internal/storage/postgres/mapping_test.go:405](/config/workspace/alt/services/worker/internal/storage/postgres/mapping_test.go:405)는 이름과 달리 `"token"` payload가 marshal되는지만 확인해 계획의 raw secret/account/provider-body rejection을 보장하지 못한다. append 경계에서 `trading.HasRawSecretKey`를 적용하고, `token`, `raw_account_number`, raw provider body 계열 key가 error로 거부되는 테스트를 추가하라.
### 다음 단계
FAIL이므로 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md` follow-up을 작성한다. `USER_REVIEW.md`는 만들지 않는다.
### 리뷰 중 실행한 검증
```text
$ bin/worker-storage-check
Generating worker storage code via sqlc...
Generation complete.
$ bin/contracts-check
(exit 0, no output)
$ go test ./packages/domain/... ./packages/contracts/gen/go/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading (cached)
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
$ go test ./services/worker/...
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker (cached)
? 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 (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
? 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 (cached)
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ go test ./services/api/...
? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files]
ok git.toki-labs.com/toki/alt/services/api/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/socket (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/workerclient (cached)
$ go test ./apps/cli/...
? git.toki-labs.com/toki/alt/apps/cli/cmd/alt [no test files]
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ bin/lint
Analyzing client...
13 existing avoid_print info entries in apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart.
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +1: All tests passed!
$ gofmt -l ...
services/worker/internal/socket/live_test.go
apps/cli/internal/operator/client_test.go
$ gofmt -w services/worker/internal/socket/live_test.go apps/cli/internal/operator/client_test.go
(exit 0, no output)
$ go test ./services/worker/internal/socket ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ git diff --check
(exit 0, no output)
```

View file

@ -0,0 +1,355 @@
<!-- task=m-live-trading-boundary/05+02,03,04_audit_trail plan=1 tag=REVIEW_LIVE_AUDIT -->
# Code Review Reference - REVIEW_LIVE_AUDIT
> **[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-08
task=m-live-trading-boundary/05+02,03,04_audit_trail, plan=1, tag=REVIEW_LIVE_AUDIT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `audit-trail`: live operation audit trail이 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/05+02,03,04_audit_trail/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_LIVE_AUDIT-1] worker bootstrap에 live service와 audit store를 배선한다. | [x] |
| [REVIEW_LIVE_AUDIT-2] 성공 submit audit event를 `submit_confirmed`와 `broker_accepted`/`broker_rejected`로 분리한다. | [x] |
| [REVIEW_LIVE_AUDIT-3] forbidden audit payload key를 append 경계에서 거부하고 테스트를 보강한다. | [x] |
| [REVIEW_LIVE_AUDIT-4] focused verification과 최종 검증을 실행한다. | [x] |
## 구현 체크리스트
- [x] [REVIEW_LIVE_AUDIT-1] worker bootstrap에 live service와 audit store를 배선한다.
- [x] [REVIEW_LIVE_AUDIT-2] 성공 submit audit event를 `submit_confirmed`와 `broker_accepted`/`broker_rejected`로 분리한다.
- [x] [REVIEW_LIVE_AUDIT-3] forbidden audit payload key를 append 경계에서 거부하고 테스트를 보강한다.
- [x] [REVIEW_LIVE_AUDIT-4] focused verification과 최종 검증을 실행한다.
- [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하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-live-trading-boundary/05+02,03,04_audit_trail/`를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/05+02,03,04_audit_trail/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-live-trading-boundary/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
- **submit_confirmed 이벤트에 OrderID 없음**: 계획에서는 명시하지 않았으나, `submit_confirmed`는 broker 호출 이전에 발행되기 때문에 아직 OrderID가 없다. 이 이벤트의 Payload에는 `instrument_id`, `side`, `type`만 포함된다. OrderID는 broker 응답 이후 발행되는 `broker_accepted`/`broker_rejected`에만 포함된다.
- **lint 검증 기록 보완**: 구현 중 `flutter analyze --no-fatal-infos`를 직접 실행해 Flutter analyzer 출력을 남겼다. 리뷰 중 `bin/lint`가 존재함을 확인하고 같은 기존 Mattermost `avoid_print` info 13건만 출력되는 것을 재검증했다.
## 주요 설계 결정
- **nil broker + audit store 조합**: `livetrading.NewService(nil)`로 broker 없이 서비스를 생성하고 `SetAuditStore(store)`를 통해 audit store만 주입했다. 이렇게 하면 live 주문 실행 기능은 broker가 연결될 때까지 비활성이지만, audit 이벤트 조회(`ListLiveAuditEvents`)는 bootstrap 직후부터 동작한다.
- **두 단계 이벤트 패턴**: `SubmitLiveOrder`에서 broker 호출 전 `submit_confirmed`를, broker 응답 후 `broker_accepted` 또는 `broker_rejected`를 발행한다. operator gate 통과(submit_confirmed)와 broker 결과(accepted/rejected)를 별도 이벤트로 분리하여 장애 시 어느 단계에서 중단되었는지 추적 가능하다.
- **defense in depth 금지 키 검사**: `HasRawSecretKey` 검사를 `appendAudit`(서비스 레이어)와 `AppendLiveAuditEvent`(postgres store 레이어) 두 곳에 모두 적용한다. 서비스 레이어는 store를 호출하기 전 차단하고, store 레이어는 다른 경로에서 직접 호출될 경우를 대비한 방어선이다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `storeBackedDeps`가 `Deps.Live`를 채우고 audit store를 주입하는지 확인한다.
- 실제 worker handler에서 `ListLiveAuditEvents`가 nil live service 때문에 unavailable로 떨어지지 않는지 확인한다.
- 성공 submit에서 `submit_confirmed`와 `broker_accepted`가 모두 append되는지 확인한다.
- rejected broker result에서 `broker_rejected`가 append되는지 확인한다.
- forbidden payload key가 postgres append/mapping 경계에서 error로 거부되는지 확인한다.
- verification output이 실제 재실행 결과와 일치하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_LIVE_AUDIT-1 중간 검증
```text
$ go test ./services/worker/cmd/alt-worker ./services/worker/internal/socket
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker 0.006s
ok git.toki-labs.com/toki/alt/services/worker/internal/socket 0.068s
```
### REVIEW_LIVE_AUDIT-2 중간 검증
```text
$ go test ./services/worker/internal/livetrading -run 'TestSubmitLiveOrder.*Audit|TestCancelLiveOrderEmitsCancelConfirmedAudit|TestSyncLiveAccount.*Audit|TestListLiveAuditEvents' -count=1 -v
=== RUN TestSubmitLiveOrderEmitsSubmitConfirmedAudit
--- PASS: TestSubmitLiveOrderEmitsSubmitConfirmedAudit (0.00s)
=== RUN TestSubmitLiveOrderBrokerRejectedEmitsBothAuditEvents
--- PASS: TestSubmitLiveOrderBrokerRejectedEmitsBothAuditEvents (0.00s)
=== RUN TestSubmitLiveOrderRiskBlockedEmitsRiskBlockedAudit
--- PASS: TestSubmitLiveOrderRiskBlockedEmitsRiskBlockedAudit (0.00s)
=== RUN TestSubmitLiveOrderAuditFailureReturnsErrAuditFailed
--- PASS: TestSubmitLiveOrderAuditFailureReturnsErrAuditFailed (0.00s)
=== RUN TestCancelLiveOrderEmitsCancelConfirmedAudit
--- PASS: TestCancelLiveOrderEmitsCancelConfirmedAudit (0.00s)
=== RUN TestSyncLiveAccountEmitsAccountSyncSuccessAudit
--- PASS: TestSyncLiveAccountEmitsAccountSyncSuccessAudit (0.00s)
=== RUN TestSyncLiveAccountBrokerErrorEmitsAccountSyncFailureAudit
--- PASS: TestSyncLiveAccountBrokerErrorEmitsAccountSyncFailureAudit (0.00s)
=== RUN TestListLiveAuditEventsNoStore
--- PASS: TestListLiveAuditEventsNoStore (0.00s)
=== RUN TestListLiveAuditEventsDelegatesFilter
--- PASS: TestListLiveAuditEventsDelegatesFilter (0.00s)
PASS
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading 0.002s
```
### REVIEW_LIVE_AUDIT-3 중간 검증
```text
$ go test ./packages/domain/... ./services/worker/internal/storage/postgres/... -run 'TestHasRawSecretKey|TestAuditEventMapping|TestStoreLiveAudit' -count=1 -v
testing: warning: no tests to run
PASS
ok git.toki-labs.com/toki/alt/packages/domain/backtest 0.002s [no tests to run]
testing: warning: no tests to run
PASS
ok git.toki-labs.com/toki/alt/packages/domain/market 0.003s [no tests to run]
=== RUN TestHasRawSecretKeyBlocksForbiddenKeys
--- PASS: TestHasRawSecretKeyBlocksForbiddenKeys (0.00s)
=== RUN TestHasRawSecretKeyAllowsSafePayload
--- PASS: TestHasRawSecretKeyAllowsSafePayload (0.00s)
PASS
ok git.toki-labs.com/toki/alt/packages/domain/trading 0.004s
=== RUN TestAuditEventMappingRoundTrip
--- PASS: TestAuditEventMappingRoundTrip (0.00s)
=== RUN TestAuditEventMappingRejectsRawSecretKey
--- PASS: TestAuditEventMappingRejectsRawSecretKey (0.00s)
=== RUN TestStoreLiveAuditAppendRejectsForbiddenPayload
--- PASS: TestStoreLiveAuditAppendRejectsForbiddenPayload (0.00s)
=== RUN TestStoreLiveAuditAppendAndList
--- PASS: TestStoreLiveAuditAppendAndList (0.35s)
PASS
ok git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres 0.360s
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
```
### 최종 검증
```text
$ gofmt -l services/worker/cmd/alt-worker/main.go services/worker/cmd/alt-worker/main_test.go services/worker/internal/livetrading/service.go services/worker/internal/livetrading/service_test.go services/worker/internal/storage/postgres/store.go services/worker/internal/storage/postgres/mapping.go services/worker/internal/storage/postgres/mapping_test.go packages/domain/trading/audit.go packages/domain/trading/audit_test.go
(출력 없음 — 포맷 일치)
$ bin/worker-storage-check
Generating worker storage code via sqlc...
Generation complete.
$ bin/contracts-check
(출력 없음 — exit 0)
$ go test ./packages/domain/... ./packages/contracts/gen/go/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading (cached)
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
$ go test ./services/worker/...
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker (cached)
? 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 (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
? 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 (cached)
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ go test ./services/api/...
? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files]
ok git.toki-labs.com/toki/alt/services/api/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/socket (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/workerclient (cached)
$ go test ./apps/cli/...
? git.toki-labs.com/toki/alt/apps/cli/cmd/alt [no test files]
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ flutter test test/contracts/alt_contracts_test.dart # (apps/client 디렉터리)
00:00 +0: loading /config/workspace/alt/apps/client/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!
$ flutter analyze --no-fatal-infos # 구현 중 직접 실행; 리뷰 중 bin/lint도 별도 재실행
Analyzing client...
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:27:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:35:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:41:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:44:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:51:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:55:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:101:5 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:123:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:132:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:134:9 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:137:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:154:7 • avoid_print
info • Don't invoke 'print' in production code • lib/src/integrations/mattermost/mattermost_auth_service.dart:156:7 • avoid_print
13 issues found. (ran in 3.3s)
(13 info 모두 기존 mattermost_auth_service.dart의 avoid_print — 변경 범위 외)
$ git diff --check
(출력 없음 — trailing whitespace 없음)
```
---
> **[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.
## 코드리뷰 결과
### 종합 판정
PASS
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| correctness | Pass | `Deps.Live`가 worker bootstrap에 배선됐고, live audit query는 store-backed live service를 통한다. 성공/rejected submit audit event도 operator gate와 broker result로 분리됐다. |
| completeness | Pass | follow-up Required 3건이 모두 구현됐고 active review 체크리스트와 Roadmap Targets가 유지됐다. |
| test coverage | Pass | worker deps wiring, submit/broker audit events, service/store forbidden payload rejection 테스트가 추가됐다. |
| API contract | Pass | worker live service가 nil이 아니므로 API/CLI `ListLiveAuditEvents` forwarding이 worker unavailable으로만 떨어지는 문제를 닫았다. |
| code quality | Pass | gofmt, `git diff --check`, focused tests가 통과했고 debug/TODO 출력은 발견되지 않았다. |
| plan deviation | Pass | nil broker + audit store 배선과 `submit_confirmed` OrderID 생략은 범위 안에서 설명 가능하다. |
| verification trust | Pass | 구현자가 남긴 검증을 재실행했고, 누락됐던 실제 `bin/lint`도 리뷰 중 재실행해 통과를 확인했다. |
### 발견된 문제
없음
### 다음 단계
PASS이므로 `complete.log`를 작성하고 active task directory를 archive로 이동한다. `m-live-trading-boundary` 완료 이벤트 메타데이터를 보고한다.
### 리뷰 중 실행한 검증
```text
$ go test ./services/worker/cmd/alt-worker ./services/worker/internal/socket
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
$ go test ./services/worker/internal/livetrading -run 'TestSubmitLiveOrder.*Audit|TestCancelLiveOrderEmitsCancelConfirmedAudit|TestSyncLiveAccount.*Audit|TestListLiveAuditEvents|TestAppendAuditRejectsForbiddenPayloadKey' -count=1 -v
PASS; focused audit emission/list/rejection tests passed.
$ go test ./packages/domain/... ./services/worker/internal/storage/postgres/... -run 'TestHasRawSecretKey|TestAuditEventMapping|TestStoreLiveAudit' -count=1 -v
PASS; domain forbidden-key and postgres audit mapping/store tests passed.
$ gofmt -l services/worker/cmd/alt-worker/main.go services/worker/cmd/alt-worker/main_test.go services/worker/internal/livetrading/service.go services/worker/internal/livetrading/service_test.go services/worker/internal/storage/postgres/store.go services/worker/internal/storage/postgres/mapping.go services/worker/internal/storage/postgres/mapping_test.go packages/domain/trading/audit.go packages/domain/trading/audit_test.go
(exit 0, no output)
$ bin/worker-storage-check
Generating worker storage code via sqlc...
Generation complete.
$ bin/contracts-check
(exit 0, no output)
$ go test ./packages/domain/... ./packages/contracts/gen/go/...
ok git.toki-labs.com/toki/alt/packages/domain/backtest (cached)
ok git.toki-labs.com/toki/alt/packages/domain/market (cached)
ok git.toki-labs.com/toki/alt/packages/domain/trading (cached)
? git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1 [no test files]
$ go test ./services/worker/...
ok git.toki-labs.com/toki/alt/services/worker/cmd/alt-worker (cached)
? 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 (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/backtest (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/jobs (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/livetrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/datacheck (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/marketdata/importer (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/papertrading (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/providers/kis (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/rediskeys (cached)
ok git.toki-labs.com/toki/alt/services/worker/internal/socket (cached)
? 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 (cached)
? git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc [no test files]
$ go test ./services/api/...
? git.toki-labs.com/toki/alt/services/api/cmd/alt-api [no test files]
ok git.toki-labs.com/toki/alt/services/api/internal/config (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/contracts (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/socket (cached)
ok git.toki-labs.com/toki/alt/services/api/internal/workerclient (cached)
$ go test ./apps/cli/...
? git.toki-labs.com/toki/alt/apps/cli/cmd/alt [no test files]
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
$ cd apps/client && flutter test test/contracts/alt_contracts_test.dart
00:00 +1: All tests passed!
$ bin/lint
exit 0; existing Mattermost avoid_print info entries only.
$ git diff --check
(exit 0, no output)
```

View file

@ -0,0 +1,54 @@
# Complete - m-live-trading-boundary/05+02,03,04_audit_trail
## 완료 일시
2026-06-08
## 요약
Live Trading Boundary의 `audit-trail` subtask를 2회 리뷰 루프 끝에 PASS로 종료했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | worker live deps 미배선, `broker_accepted` audit event 누락, forbidden payload append rejection 누락을 Required로 확인했다. |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | worker live service + audit store wiring, submit/broker audit event 분리, payload rejection defense가 완료됐다. |
## 구현/정리 내용
- worker bootstrap에서 `livetrading.Service`를 생성하고 postgres store를 audit store로 주입해 `ListLiveAuditEvents` runtime surface를 열었다.
- 성공 submit audit trail을 `submit_confirmed`와 `broker_accepted`/`broker_rejected` event로 분리했다.
- service/store append 경계에서 forbidden audit payload key를 거부하고 관련 tests를 보강했다.
- 리뷰 중 `bin/lint` 존재 여부를 재확인하고 실제 `bin/lint` 결과로 verification evidence를 보강했다.
## 최종 검증
- `go test ./services/worker/cmd/alt-worker ./services/worker/internal/socket` - PASS; worker bootstrap/socket packages passed.
- `go test ./services/worker/internal/livetrading -run 'TestSubmitLiveOrder.*Audit|TestCancelLiveOrderEmitsCancelConfirmedAudit|TestSyncLiveAccount.*Audit|TestListLiveAuditEvents|TestAppendAuditRejectsForbiddenPayloadKey' -count=1 -v` - PASS; focused audit emission/list/rejection tests passed.
- `go test ./packages/domain/... ./services/worker/internal/storage/postgres/... -run 'TestHasRawSecretKey|TestAuditEventMapping|TestStoreLiveAudit' -count=1 -v` - PASS; domain forbidden-key and postgres audit mapping/store tests passed.
- `gofmt -l services/worker/cmd/alt-worker/main.go services/worker/cmd/alt-worker/main_test.go services/worker/internal/livetrading/service.go services/worker/internal/livetrading/service_test.go services/worker/internal/storage/postgres/store.go services/worker/internal/storage/postgres/mapping.go services/worker/internal/storage/postgres/mapping_test.go packages/domain/trading/audit.go packages/domain/trading/audit_test.go` - PASS; no output.
- `bin/worker-storage-check` - PASS; generated worker storage output current.
- `bin/contracts-check` - PASS; no output.
- `go test ./packages/domain/... ./packages/contracts/gen/go/...` - PASS.
- `go test ./services/worker/...` - PASS.
- `go test ./services/api/...` - PASS.
- `go test ./apps/cli/...` - PASS.
- `cd apps/client && flutter test test/contracts/alt_contracts_test.dart` - PASS; all tests passed.
- `bin/lint` - PASS; exit code 0 with existing Mattermost `avoid_print` analyzer info entries only.
- `git diff --check` - PASS; no output.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Completed task ids:
- `audit-trail`: PASS; evidence=`plan_cloud_G07_1.log`, `code_review_cloud_G07_1.log`; verification=`go test ./services/worker/cmd/alt-worker ./services/worker/internal/socket`, `go test ./services/worker/internal/livetrading -run 'TestSubmitLiveOrder.*Audit|TestCancelLiveOrderEmitsCancelConfirmedAudit|TestSyncLiveAccount.*Audit|TestListLiveAuditEvents|TestAppendAuditRejectsForbiddenPayloadKey' -count=1 -v`, `go test ./packages/domain/... ./services/worker/internal/storage/postgres/... -run 'TestHasRawSecretKey|TestAuditEventMapping|TestStoreLiveAudit' -count=1 -v`, `bin/worker-storage-check`, `bin/contracts-check`, `go test ./packages/domain/... ./packages/contracts/gen/go/...`, `go test ./services/worker/...`, `go test ./services/api/...`, `go test ./apps/cli/...`, `cd apps/client && flutter test test/contracts/alt_contracts_test.dart`, `bin/lint`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,199 @@
<!-- task=m-live-trading-boundary/05+02,03,04_audit_trail plan=1 tag=REVIEW_LIVE_AUDIT -->
# Plan - REVIEW_LIVE_AUDIT
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것은 필수다. 구현 후 검증을 실행하고 실제 변경 내용, 설계 결정, 명령 출력(stdout/stderr)을 기록한 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review 전용이다.
구현 중 사용자만 결정할 수 있는 사항, 사용자 소유 외부 환경/secret, 또는 계획 범위 충돌 없이는 안전하게 진행할 수 없으면 active review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정, 근거, 실행 명령/출력, 재개 조건을 기록하고 중단한다. 구현 에이전트는 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
1차 리뷰(`code_review_cloud_G07_0.log`)는 live audit trail 구현을 `FAIL`로 판정했다. 생성물 check와 Go/Flutter focused 검증은 통과했지만, 실제 worker process가 `Deps.Live`를 배선하지 않아 API/CLI audit query가 runtime에서 unavailable로 떨어지고, 성공 주문 audit event와 raw payload rejection 요구가 계획 대비 누락됐다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `audit-trail`: live operation audit trail이 있다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-task/m-live-trading-boundary/05+02,03,04_audit_trail/plan_cloud_G07_0.log`
- `agent-task/m-live-trading-boundary/05+02,03,04_audit_trail/code_review_cloud_G07_0.log`
- `services/worker/cmd/alt-worker/main.go`
- `services/worker/cmd/alt-worker/main_test.go`
- `services/worker/internal/socket/live.go`
- `services/worker/internal/socket/handlers.go`
- `services/worker/internal/livetrading/service.go`
- `services/worker/internal/livetrading/service_test.go`
- `services/worker/internal/storage/postgres/store.go`
- `services/worker/internal/storage/postgres/mapping.go`
- `services/worker/internal/storage/postgres/mapping_test.go`
### 테스트 환경 규칙
`test_env=local`이다. 이번 follow-up은 worker runtime wiring, livetrading service event emission, postgres audit storage append validation을 고친다. 필수 검증은 focused worker tests, `bin/worker-storage-check`, 그리고 최종 worker/API/CLI smoke confidence다.
### 범위 결정 근거
이 follow-up은 리뷰 Required만 고친다. KIS 실주문 BrokerPort 구현, Flutter 화면, audit 상세 CLI row rendering 확장은 범위 밖이다. 현재 KIS `LiveProvider`가 `livetrading.BrokerPort`를 구현하지 않으면, worker bootstrap은 `livetrading.NewService(nil)`로 live service를 만들고 audit store만 주입해 query surface를 열어도 된다. 이 경우 order/sync broker operations는 기존처럼 typed unavailable을 반환하되 `ListLiveAuditEvents`는 durable store를 조회해야 한다.
### 빌드 등급
Build lane `cloud-G07`, review lane `cloud-G07`. Worker bootstrap/runtime contract, audit persistence safety, operation event semantics가 함께 걸려 이전 cloud route를 유지한다.
## 구현 체크리스트
- [ ] [REVIEW_LIVE_AUDIT-1] worker bootstrap에 live service와 audit store를 배선한다.
- [ ] [REVIEW_LIVE_AUDIT-2] 성공 submit audit event를 `submit_confirmed`와 `broker_accepted`/`broker_rejected`로 분리한다.
- [ ] [REVIEW_LIVE_AUDIT-3] forbidden audit payload key를 append 경계에서 거부하고 테스트를 보강한다.
- [ ] [REVIEW_LIVE_AUDIT-4] focused verification과 최종 검증을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_LIVE_AUDIT-1] Worker Runtime Wiring
#### 문제
`services/worker/cmd/alt-worker/main.go:96`의 `storeBackedDeps`가 `socket.Deps.Live`를 채우지 않는다. `services/worker/internal/socket/live.go:397`은 `deps.Live == nil`이면 `ListLiveAuditEvents`를 `unavailable`로 반환하므로 API/CLI audit query workflow가 실제 worker process에서 동작하지 않는다.
#### 해결 방법
`storeBackedDeps`에서 live service를 생성하고, 같은 postgres store를 audit store로 주입한다.
예시:
```go
liveService := livetrading.NewService(nil)
liveService.SetAuditStore(store)
```
KIS broker port가 현재 `livetrading.BrokerPort`를 구현하지 않으면 nil broker를 유지한다. 이 follow-up은 broker adapter 구현이 아니라 durable audit query surface 배선이 목표다.
#### 수정 파일 및 체크리스트
- [ ] `services/worker/cmd/alt-worker/main.go`에 `livetrading` import와 `Live: liveService` 배선을 추가한다.
- [ ] `services/worker/cmd/alt-worker/main_test.go`가 `deps.Live != nil`을 검증한다.
- [ ] 같은 테스트에서 `deps.Live.ListLiveAuditEvents(context.Background(), trading.AuditFilter{AccountID: "acct"})` 호출이 nil deps unavailable가 아니라 store-backed path를 타는지 검증한다. nil pool store가 실제 DB 호출까지 가면 안 되므로, 가능한 범위에서는 fake/narrow store 또는 no-op assertion으로 배선만 확인한다.
#### 테스트 작성
작성한다. 최소한 `storeBackedDeps`가 live service를 반환하고 hello capability에 `live-trading`이 포함될 수 있는 조건을 보장한다.
#### 중간 검증
```bash
go test ./services/worker/cmd/alt-worker ./services/worker/internal/socket
```
기대 결과: exit code 0.
### [REVIEW_LIVE_AUDIT-2] Accepted Broker Audit Event
#### 문제
`services/worker/internal/livetrading/service.go:343`은 성공 submit 결과를 `submit_confirmed` 하나로만 기록하고, rejected일 때만 `broker_rejected`로 바꾼다. `broker_accepted` constant는 정의되어 있지만 사용되지 않는다. 계획은 operator confirmation과 broker response를 분리된 audit event로 남기는 것이다.
#### 해결 방법
`SubmitLiveOrder` 성공 경로에서 audit append를 두 단계로 분리한다.
- operator confirmation event: `submit_confirmed`
- broker response event: `broker_accepted` when `order.Status != trading.OrderStatusRejected`, otherwise `broker_rejected`
두 event 모두 raw token/account/provider payload 없이 sanitized `map[string]string`만 담는다. 기존 mandatory audit policy를 유지해 append failure는 `ErrAuditFailed`로 반환한다.
#### 수정 파일 및 체크리스트
- [ ] `services/worker/internal/livetrading/service.go`에서 성공 submit audit event를 두 개로 분리한다.
- [ ] `services/worker/internal/livetrading/service_test.go`의 success test가 `submit_confirmed`와 `broker_accepted`를 모두 확인하게 보강한다.
- [ ] broker rejected result test가 없으면 추가해 `broker_rejected` event를 확인한다.
#### 테스트 작성
작성한다. 성공 submit에서 event type 순서와 `order_id`, `actor`, `broker_order_id` payload가 기대대로 들어가는지 확인한다.
#### 중간 검증
```bash
go test ./services/worker/internal/livetrading -run 'TestSubmitLiveOrder.*Audit|TestCancelLiveOrderEmitsCancelConfirmedAudit|TestSyncLiveAccount.*Audit|TestListLiveAuditEvents' -count=1 -v
```
기대 결과: exit code 0.
### [REVIEW_LIVE_AUDIT-3] Audit Payload Rejection
#### 문제
`services/worker/internal/storage/postgres/store.go:220`과 `services/worker/internal/storage/postgres/mapping.go:335`는 payload를 그대로 JSONB로 저장한다. `mapping_test.go:405`의 테스트 이름은 raw secret rejection처럼 보이지만 실제로는 `"token"` payload가 marshal되는지만 확인한다.
#### 해결 방법
append 경계에서 forbidden payload key를 거부한다. storage-level `AppendLiveAuditEvent` 또는 `mapAuditEventToParams` 중 한 곳에 `trading.HasRawSecretKey(event.Payload)` 검사를 두고, error message는 테스트 가능한 문자열을 포함한다. 필요하면 domain blocked key 목록을 `account_number`, `raw_provider_body`, `raw_provider_response`, `provider_response`처럼 계획에서 금지한 provider/account raw payload 표현까지 확장한다.
#### 수정 파일 및 체크리스트
- [ ] `packages/domain/trading/audit.go`의 forbidden key 목록이 계획의 raw token/account/provider-body 금지 범위를 커버하는지 보강한다.
- [ ] `services/worker/internal/storage/postgres/store.go` 또는 `mapping.go`에서 forbidden key append를 error로 막는다.
- [ ] `services/worker/internal/storage/postgres/mapping_test.go`의 raw secret 테스트를 실제 rejection assertion으로 바꾼다.
- [ ] 필요하면 `packages/domain/trading/audit_test.go`에 새 forbidden key case를 추가한다.
#### 테스트 작성
작성한다. `token`, `raw_account_number`, provider raw body/response 계열 key가 append/mapping에서 error를 반환해야 한다. safe payload는 기존 round trip이 유지되어야 한다.
#### 중간 검증
```bash
go test ./packages/domain/... ./services/worker/internal/storage/postgres -run 'TestHasRawSecretKey|TestAuditEventMapping|TestStoreLiveAudit' -count=1 -v
```
기대 결과: exit code 0.
### [REVIEW_LIVE_AUDIT-4] Verification
#### 문제
1차 검증 명령은 모두 통과했지만 Required 동작을 잡는 테스트가 없었다. Follow-up은 focused failure tests를 추가한 뒤 전체 affected smoke를 다시 실행해야 한다.
#### 해결 방법
리뷰 Required를 재현하는 테스트가 먼저 실패하는 구조로 작성하고, 구현 후 전체 affected 명령을 실행한다.
#### 수정 파일 및 체크리스트
- [ ] `gofmt -l`가 빈 출력인지 확인한다.
- [ ] `bin/worker-storage-check`를 실행한다.
- [ ] `bin/contracts-check`를 실행한다.
- [ ] `go test ./packages/domain/... ./packages/contracts/gen/go/...`를 실행한다.
- [ ] `go test ./services/worker/...`를 실행한다.
- [ ] `go test ./services/api/...`를 실행한다.
- [ ] `go test ./apps/cli/...`를 실행한다.
- [ ] `cd apps/client && flutter test test/contracts/alt_contracts_test.dart`를 실행한다.
- [ ] `bin/lint`를 실행한다.
- [ ] `git diff --check`를 실행한다.
#### 테스트 작성
REVIEW_LIVE_AUDIT-1부터 REVIEW_LIVE_AUDIT-3에 포함한다.
#### 중간 검증
```bash
gofmt -l services/worker/cmd/alt-worker/main.go services/worker/cmd/alt-worker/main_test.go services/worker/internal/livetrading/service.go services/worker/internal/livetrading/service_test.go services/worker/internal/storage/postgres/store.go services/worker/internal/storage/postgres/mapping.go services/worker/internal/storage/postgres/mapping_test.go packages/domain/trading/audit.go packages/domain/trading/audit_test.go
bin/worker-storage-check
bin/contracts-check
go test ./packages/domain/... ./packages/contracts/gen/go/...
go test ./services/worker/...
go test ./services/api/...
go test ./apps/cli/...
cd apps/client && flutter test test/contracts/alt_contracts_test.dart
bin/lint
git diff --check
```
기대 결과: 모든 명령 exit code 0. `bin/lint`가 기존 Mattermost `avoid_print` info만 출력하면 기존 이슈로 기록한다.

View file

@ -1,159 +0,0 @@
<!-- task=m-live-trading-boundary/04+01_account_sync plan=0 tag=LIVE_ACCOUNT -->
# Code Review Reference - LIVE_ACCOUNT
> **[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-07
task=m-live-trading-boundary/04+01_account_sync, plan=0, tag=LIVE_ACCOUNT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `account-sync`: 계좌/포지션 동기화 경계가 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/04+01_account_sync/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [LIVE_ACCOUNT-1] account snapshot domain/contract를 확장한다. | [ ] |
| [LIVE_ACCOUNT-2] worker broker account sync service와 socket handlers를 추가한다. | [ ] |
| [LIVE_ACCOUNT-3] API forwarder와 CLI headless account sync workflow를 추가한다. | [ ] |
| [LIVE_ACCOUNT-4] focused verification과 affected module tests를 실행한다. | [ ] |
## 구현 체크리스트
- [ ] [LIVE_ACCOUNT-1] account snapshot domain/contract를 확장한다.
- [ ] [LIVE_ACCOUNT-2] worker broker account sync service와 socket handlers를 추가한다.
- [ ] [LIVE_ACCOUNT-3] API forwarder와 CLI headless account sync workflow를 추가한다.
- [ ] [LIVE_ACCOUNT-4] focused verification과 affected module tests를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과``PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md``code_review_cloud_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md``plan_cloud_G05_M.log`로 아카이브한다.
- [ ] `.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-live-trading-boundary/04+01_account_sync/``agent-task/archive/YYYY/MM/m-live-trading-boundary/04+01_account_sync/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-live-trading-boundary/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `01_broker_port` completion이 prerequisite로 충족됐는지 확인한다.
- Raw account number, provider raw payload, secret이 tracked fixtures/output에 들어오지 않았는지 확인한다.
- Account snapshot이 worker-owned broker port를 통해 들어오고 API는 thin forwarder인지 확인한다.
- Durable storage가 이 subtask에 섞이지 않았는지 확인한다.
- CLI output keys가 headless validation matrix와 expected JSONL에 반영됐는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### LIVE_ACCOUNT-1 중간 검증
```text
$ bin/contracts-gen
(output)
$ bin/contracts-check
(output)
$ go test ./packages/domain/... ./packages/contracts/gen/go/...
(output)
```
### LIVE_ACCOUNT-2 중간 검증
```text
$ go test ./services/worker/internal/livetrading ./services/worker/internal/socket
(output)
```
### LIVE_ACCOUNT-3 중간 검증
```text
$ go test ./services/api/internal/socket ./apps/cli/internal/operator
(output)
```
### 최종 검증
```text
$ bin/contracts-check
(output)
$ go test ./packages/domain/...
(output)
$ go test ./packages/contracts/gen/go/...
(output)
$ go test ./services/worker/...
(output)
$ go test ./services/api/...
(output)
$ go test ./apps/cli/...
(output)
$ bin/lint
(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.

View file

@ -1,173 +0,0 @@
<!-- task=m-live-trading-boundary/05+02,03,04_audit_trail plan=0 tag=LIVE_AUDIT -->
# Code Review Reference - LIVE_AUDIT
> **[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-07
task=m-live-trading-boundary/05+02,03,04_audit_trail, plan=0, tag=LIVE_AUDIT
## Roadmap Targets
- Milestone: `agent-roadmap/phase/trading-expansion/milestones/live-trading-boundary.md`
- Task ids:
- `audit-trail`: live operation audit trail이 있다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-live-trading-boundary/05+02,03,04_audit_trail/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [LIVE_AUDIT-1] live audit domain/contract event shape를 확장한다. | [ ] |
| [LIVE_AUDIT-2] storage port, migration, sqlc query, postgres store를 추가한다. | [ ] |
| [LIVE_AUDIT-3] worker live operations에 audit writer와 query handler를 연결한다. | [ ] |
| [LIVE_AUDIT-4] API/CLI audit query workflow와 fixtures를 추가한다. | [ ] |
| [LIVE_AUDIT-5] storage/generated/focused verification을 실행한다. | [ ] |
## 구현 체크리스트
- [ ] [LIVE_AUDIT-1] live audit domain/contract event shape를 확장한다.
- [ ] [LIVE_AUDIT-2] storage port, migration, sqlc query, postgres store를 추가한다.
- [ ] [LIVE_AUDIT-3] worker live operations에 audit writer와 query handler를 연결한다.
- [ ] [LIVE_AUDIT-4] API/CLI audit query workflow와 fixtures를 추가한다.
- [ ] [LIVE_AUDIT-5] storage/generated/focused verification을 실행한다.
- [ ] 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이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-live-trading-boundary/05+02,03,04_audit_trail/``agent-task/archive/YYYY/MM/m-live-trading-boundary/05+02,03,04_audit_trail/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-live-trading-boundary/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `02+01_order_lifecycle`, `03+01,02_risk_kill_switch`, `04+01_account_sync` completion이 prerequisite로 충족됐는지 확인한다.
- Migration 000004가 새 파일로 추가됐고 기존 migration을 수정하지 않았는지 확인한다.
- `bin/worker-storage-check`와 sqlc generated output이 일치하는지 확인한다.
- Audit payload에 raw token, raw account number, raw provider response가 저장되지 않는지 확인한다.
- Worker operation이 audit append 실패를 무시하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### LIVE_AUDIT-1 중간 검증
```text
$ bin/contracts-gen
(output)
$ bin/contracts-check
(output)
$ go test ./packages/domain/... ./packages/contracts/gen/go/...
(output)
```
### LIVE_AUDIT-2 중간 검증
```text
$ bin/worker-storage-gen
(output)
$ bin/worker-storage-check
(output)
$ go test ./services/worker/internal/storage/...
(output)
```
### LIVE_AUDIT-3 중간 검증
```text
$ go test ./services/worker/internal/livetrading ./services/worker/internal/socket
(output)
```
### LIVE_AUDIT-4 중간 검증
```text
$ go test ./services/api/internal/socket ./apps/cli/internal/operator
(output)
```
### 최종 검증
```text
$ bin/worker-storage-check
(output)
$ bin/contracts-check
(output)
$ go test ./packages/domain/...
(output)
$ go test ./packages/contracts/gen/go/...
(output)
$ go test ./services/worker/...
(output)
$ go test ./services/api/...
(output)
$ go test ./apps/cli/...
(output)
$ bin/lint
(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.

View file

@ -201,6 +201,21 @@ func (c *APIClient) SetLiveKillSwitch(ctx context.Context, req *altv1.SetLiveKil
return sendTyped[*altv1.SetLiveKillSwitchRequest, *altv1.SetLiveKillSwitchResponse](c, ctx, req)
}
// SyncLiveAccount fetches a fresh account snapshot from the broker via the worker.
func (c *APIClient) SyncLiveAccount(ctx context.Context, req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
return sendTyped[*altv1.SyncLiveAccountRequest, *altv1.SyncLiveAccountResponse](c, ctx, req)
}
// GetLiveAccountSnapshot retrieves the last synced account snapshot from the worker.
func (c *APIClient) GetLiveAccountSnapshot(ctx context.Context, req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
return sendTyped[*altv1.GetLiveAccountSnapshotRequest, *altv1.GetLiveAccountSnapshotResponse](c, ctx, req)
}
// ListLiveAuditEvents queries the durable live operation audit trail.
func (c *APIClient) ListLiveAuditEvents(ctx context.Context, req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
return sendTyped[*altv1.ListLiveAuditEventsRequest, *altv1.ListLiveAuditEventsResponse](c, ctx, req)
}
// sendTyped is the shared request path for every API call. It derives the
// request timeout from the context deadline and maps a dropped connection to
// ErrTransport so each method stays a one-line forwarder and the transport vs.

View file

@ -41,27 +41,33 @@ type fakeAPI struct {
getLiveRiskPolicyResp *altv1.GetLiveRiskPolicyResponse
getLiveKillSwitchResp *altv1.GetLiveKillSwitchResponse
setLiveKillSwitchResp *altv1.SetLiveKillSwitchResponse
syncLiveAccountResp *altv1.SyncLiveAccountResponse
getLiveAccountSnapResp *altv1.GetLiveAccountSnapshotResponse
listLiveAuditEventsResp *altv1.ListLiveAuditEventsResponse
getBacktestRunFunc func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error)
mu sync.Mutex
instReq *altv1.ListInstrumentsRequest
barsReq *altv1.ListBarsRequest
startBacktestReq *altv1.StartBacktestRequest
getBacktestRunReq *altv1.GetBacktestRunRequest
importDailyBarsReq *altv1.ImportDailyBarsRequest
startPaperReq *altv1.StartPaperTradingRequest
getPaperStateReq *altv1.GetPaperTradingStateRequest
submitPaperOrderReq *altv1.SubmitPaperOrderRequest
cancelPaperOrderReq *altv1.CancelPaperOrderRequest
fillPaperOrderReq *altv1.FillPaperOrderRequest
submitLiveOrderReq *altv1.SubmitLiveOrderRequest
cancelLiveOrderReq *altv1.CancelLiveOrderRequest
getLiveOrderReq *altv1.GetLiveOrderRequest
getLiveRiskPolicyReq *altv1.GetLiveRiskPolicyRequest
getLiveKillSwitchReq *altv1.GetLiveKillSwitchRequest
setLiveKillSwitchReq *altv1.SetLiveKillSwitchRequest
calls []string
mu sync.Mutex
instReq *altv1.ListInstrumentsRequest
barsReq *altv1.ListBarsRequest
startBacktestReq *altv1.StartBacktestRequest
getBacktestRunReq *altv1.GetBacktestRunRequest
importDailyBarsReq *altv1.ImportDailyBarsRequest
startPaperReq *altv1.StartPaperTradingRequest
getPaperStateReq *altv1.GetPaperTradingStateRequest
submitPaperOrderReq *altv1.SubmitPaperOrderRequest
cancelPaperOrderReq *altv1.CancelPaperOrderRequest
fillPaperOrderReq *altv1.FillPaperOrderRequest
submitLiveOrderReq *altv1.SubmitLiveOrderRequest
cancelLiveOrderReq *altv1.CancelLiveOrderRequest
getLiveOrderReq *altv1.GetLiveOrderRequest
getLiveRiskPolicyReq *altv1.GetLiveRiskPolicyRequest
getLiveKillSwitchReq *altv1.GetLiveKillSwitchRequest
setLiveKillSwitchReq *altv1.SetLiveKillSwitchRequest
syncLiveAccountReq *altv1.SyncLiveAccountRequest
getLiveAccountSnapReq *altv1.GetLiveAccountSnapshotRequest
listLiveAuditEventsReq *altv1.ListLiveAuditEventsRequest
calls []string
}
// recordCall appends an action name in receive order so tests can assert that a
@ -270,6 +276,42 @@ func (f *fakeAPI) lastSetLiveKillSwitchReq() *altv1.SetLiveKillSwitchRequest {
return f.setLiveKillSwitchReq
}
func (f *fakeAPI) setSyncLiveAccountReq(req *altv1.SyncLiveAccountRequest) {
f.mu.Lock()
defer f.mu.Unlock()
f.syncLiveAccountReq = req
}
func (f *fakeAPI) lastSyncLiveAccountReq() *altv1.SyncLiveAccountRequest {
f.mu.Lock()
defer f.mu.Unlock()
return f.syncLiveAccountReq
}
func (f *fakeAPI) setGetLiveAccountSnapReq(req *altv1.GetLiveAccountSnapshotRequest) {
f.mu.Lock()
defer f.mu.Unlock()
f.getLiveAccountSnapReq = req
}
func (f *fakeAPI) lastGetLiveAccountSnapReq() *altv1.GetLiveAccountSnapshotRequest {
f.mu.Lock()
defer f.mu.Unlock()
return f.getLiveAccountSnapReq
}
func (f *fakeAPI) setListLiveAuditEventsReq(req *altv1.ListLiveAuditEventsRequest) {
f.mu.Lock()
defer f.mu.Unlock()
f.listLiveAuditEventsReq = req
}
func (f *fakeAPI) lastListLiveAuditEventsReq() *altv1.ListLiveAuditEventsRequest {
f.mu.Lock()
defer f.mu.Unlock()
return f.listLiveAuditEventsReq
}
// 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
@ -449,6 +491,30 @@ func startFakeAPIServer(t *testing.T, api *fakeAPI) string {
}
return &altv1.SetLiveKillSwitchResponse{}, nil
})
protoSocket.AddRequestListenerTyped[*altv1.SyncLiveAccountRequest, *altv1.SyncLiveAccountResponse](&c.Communicator, func(req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
api.recordCall("sync_live_account")
api.setSyncLiveAccountReq(req)
if api.syncLiveAccountResp != nil {
return api.syncLiveAccountResp, nil
}
return &altv1.SyncLiveAccountResponse{}, nil
})
protoSocket.AddRequestListenerTyped[*altv1.GetLiveAccountSnapshotRequest, *altv1.GetLiveAccountSnapshotResponse](&c.Communicator, func(req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
api.recordCall("get_live_account_snapshot")
api.setGetLiveAccountSnapReq(req)
if api.getLiveAccountSnapResp != nil {
return api.getLiveAccountSnapResp, nil
}
return &altv1.GetLiveAccountSnapshotResponse{}, nil
})
protoSocket.AddRequestListenerTyped[*altv1.ListLiveAuditEventsRequest, *altv1.ListLiveAuditEventsResponse](&c.Communicator, func(req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
api.recordCall("list_live_audit_events")
api.setListLiveAuditEventsReq(req)
if api.listLiveAuditEventsResp != nil {
return api.listLiveAuditEventsResp, nil
}
return &altv1.ListLiveAuditEventsResponse{}, nil
})
}
if err := srv.Start(ctx); err != nil {

View file

@ -95,6 +95,18 @@ type StepEvent struct {
HasRiskDecision bool
RiskDecisionAllow bool
RiskDecisionReason string
// Live account snapshot output fields. They render for sync_live_account and
// get_live_account_snapshot steps.
LiveAccountID string
LiveBroker string
CashCount int
LivePositionCount int
SyncedAtUnixMs int64
LiveStale bool
// Audit query output fields. They render for list_live_audit_events steps.
AuditEventCount int
}
// RunSummary is the final record describing a whole scenario run.
@ -237,6 +249,17 @@ func (o *Writer) WriteStep(ev StepEvent) {
fields["risk_decision_reason"] = ev.RiskDecisionReason
}
}
if ev.LiveAccountID != "" {
fields["live_account_id"] = ev.LiveAccountID
fields["live_broker"] = ev.LiveBroker
fields["cash_count"] = ev.CashCount
fields["live_position_count"] = ev.LivePositionCount
fields["synced_at_unix_ms"] = ev.SyncedAtUnixMs
fields["live_stale"] = ev.LiveStale
}
if ev.Action == string(ActionListLiveAuditEvents) {
fields["audit_event_count"] = ev.AuditEventCount
}
o.writeJSON(fields)
return
}
@ -345,6 +368,13 @@ func (o *Writer) WriteStep(ev StepEvent) {
line += fmt.Sprintf(" risk_decision_reason=%q", ev.RiskDecisionReason)
}
}
if ev.LiveAccountID != "" {
line += fmt.Sprintf(" live_account_id=%s live_broker=%s cash_count=%d live_position_count=%d synced_at_unix_ms=%d live_stale=%v",
ev.LiveAccountID, ev.LiveBroker, ev.CashCount, ev.LivePositionCount, ev.SyncedAtUnixMs, ev.LiveStale)
}
if ev.Action == string(ActionListLiveAuditEvents) {
line += fmt.Sprintf(" audit_event_count=%d", ev.AuditEventCount)
}
fmt.Fprintln(o.w, line)
}

View file

@ -74,6 +74,18 @@ func messageFactories() []func() proto.Message {
func() proto.Message { return &altv1.LiveKillSwitchState{} },
// live risk decision: included in SubmitLiveOrderResponse when risk blocked
func() proto.Message { return &altv1.LiveRiskDecision{} },
// live account sync surface: sync from broker / get last snapshot
func() proto.Message { return &altv1.SyncLiveAccountRequest{} },
func() proto.Message { return &altv1.SyncLiveAccountResponse{} },
func() proto.Message { return &altv1.GetLiveAccountSnapshotRequest{} },
func() proto.Message { return &altv1.GetLiveAccountSnapshotResponse{} },
func() proto.Message { return &altv1.LiveAccountSnapshot{} },
func() proto.Message { return &altv1.LiveCashBalance{} },
func() proto.Message { return &altv1.LivePositionSnapshot{} },
// live audit trail surface: list audit events
func() proto.Message { return &altv1.ListLiveAuditEventsRequest{} },
func() proto.Message { return &altv1.ListLiveAuditEventsResponse{} },
func() proto.Message { return &altv1.LiveAuditEvent{} },
}
}

View file

@ -45,6 +45,14 @@ func TestParserMapIncludesRunnerMessages(t *testing.T) {
&altv1.GetLiveBrokerCapabilitiesRequest{},
&altv1.GetLiveBrokerCapabilitiesResponse{},
&altv1.LiveBrokerCapability{},
// live account sync surface: sync from broker / get last snapshot
&altv1.SyncLiveAccountRequest{},
&altv1.SyncLiveAccountResponse{},
&altv1.GetLiveAccountSnapshotRequest{},
&altv1.GetLiveAccountSnapshotResponse{},
&altv1.LiveAccountSnapshot{},
&altv1.LiveCashBalance{},
&altv1.LivePositionSnapshot{},
}
for _, m := range required {
name := protoSocket.TypeNameOf(m)

View file

@ -558,6 +558,39 @@ func runStep(ctx context.Context, client *APIClient, scenario string, step Step,
ev2, code := evaluateLiveKillSwitch(ev, step, sksresp.GetError(), sksresp.GetState())
return ev2, code, ""
case ActionSyncLiveAccount:
syncresp, syncerr := client.SyncLiveAccount(stepCtx, &altv1.SyncLiveAccountRequest{
AccountId: step.Request.AccountID,
})
if syncerr != nil {
return transportEvent3(ev, syncerr)
}
ev2, code := evaluateLiveAccount(ev, step, syncresp.GetError(), syncresp.GetSnapshot())
return ev2, code, ""
case ActionGetLiveAccountSnapshot:
getresp, geterr := client.GetLiveAccountSnapshot(stepCtx, &altv1.GetLiveAccountSnapshotRequest{
AccountId: step.Request.AccountID,
})
if geterr != nil {
return transportEvent3(ev, geterr)
}
ev2, code := evaluateLiveAccount(ev, step, getresp.GetError(), getresp.GetSnapshot())
return ev2, code, ""
case ActionListLiveAuditEvents:
auditresp, auditerr := client.ListLiveAuditEvents(stepCtx, &altv1.ListLiveAuditEventsRequest{
AccountId: step.Request.AccountID,
OrderId: step.Request.OrderID,
EventType: step.Request.EventType,
Limit: int32(step.Request.Limit),
})
if auditerr != nil {
return transportEvent3(ev, auditerr)
}
ev2, code := evaluateLiveAuditEvents(ev, step, auditresp.GetError(), auditresp.GetEvents())
return ev2, code, ""
default:
// Validation rejects unknown actions, so reaching here means a runner is
// missing for a registered action.
@ -1018,6 +1051,82 @@ func evaluateLiveRiskPolicy(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, p
return ev, codeOK
}
// evaluateLiveAccount checks a sync/get_live_account_snapshot response against expectations.
func evaluateLiveAccount(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, snap *altv1.LiveAccountSnapshot) (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 snap != nil {
ev.LiveAccountID = snap.GetAccountId()
ev.LiveBroker = snap.GetBroker()
ev.CashCount = len(snap.GetCash())
ev.LivePositionCount = len(snap.GetPositions())
ev.SyncedAtUnixMs = snap.GetSyncedAtUnixMs()
ev.LiveStale = snap.GetStale()
}
ev.Status = statusOK
return ev, codeOK
}
// evaluateLiveAuditEvents checks a list_live_audit_events response against expectations.
func evaluateLiveAuditEvents(ev StepEvent, step Step, errInfo *altv1.ErrorInfo, events []*altv1.LiveAuditEvent) (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
}
ev.AuditEventCount = len(events)
ev.Count = len(events)
if step.Expect.MinCount != nil && len(events) < *step.Expect.MinCount {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("expected at least %d audit events, got %d", *step.Expect.MinCount, len(events))
return ev, codeAppError
}
ev.Status = statusOK
return ev, codeOK
}
func interpolateSlice(slice []string, savedValues map[string]string) []string {
if len(slice) == 0 {
return nil

View file

@ -438,3 +438,227 @@ func TestLiveOrderLifecycleFixtureIsValid(t *testing.T) {
t.Error("expected fixture missing a summary line")
}
}
func TestRunSyncLiveAccount(t *testing.T) {
snap := &altv1.LiveAccountSnapshot{
AccountId: "op-alias-001",
Broker: "kis",
SyncedAtUnixMs: 1749340800000,
Cash: []*altv1.LiveCashBalance{
{Currency: altv1.Currency_CURRENCY_KRW, Amount: &altv1.Decimal{Value: "5000000"}},
},
}
api := &fakeAPI{
syncLiveAccountResp: &altv1.SyncLiveAccountResponse{Snapshot: snap},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_account_sync_test",
Steps: []Step{
{
ID: "sync",
Action: ActionSyncLiveAccount,
Request: Request{AccountID: "op-alias-001"},
Expect: Expect{Status: "ok"},
},
},
}
out, code := runScenario(t, sc, url)
if code != 0 {
t.Fatalf("expected exit 0, got %d\noutput:\n%s", code, out)
}
req := api.lastSyncLiveAccountReq()
if req == nil {
t.Fatal("expected server to receive SyncLiveAccountRequest")
}
if req.GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", req.GetAccountId())
}
}
func TestRunGetLiveAccountSnapshot(t *testing.T) {
snap := &altv1.LiveAccountSnapshot{
AccountId: "op-alias-001",
Broker: "kis",
SyncedAtUnixMs: 1749340800000,
}
api := &fakeAPI{
getLiveAccountSnapResp: &altv1.GetLiveAccountSnapshotResponse{Snapshot: snap},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_account_get_test",
Steps: []Step{
{
ID: "get",
Action: ActionGetLiveAccountSnapshot,
Request: Request{AccountID: "op-alias-001"},
Expect: Expect{Status: "ok"},
},
},
}
out, code := runScenario(t, sc, url)
if code != 0 {
t.Fatalf("expected exit 0, got %d\noutput:\n%s", code, out)
}
req := api.lastGetLiveAccountSnapReq()
if req == nil {
t.Fatal("expected server to receive GetLiveAccountSnapshotRequest")
}
if req.GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", req.GetAccountId())
}
}
func TestRunSyncLiveAccountValidationRejectsMissingAccountID(t *testing.T) {
sc := Scenario{
Name: "live_account_missing_id",
Steps: []Step{
{
ID: "sync",
Action: ActionSyncLiveAccount,
},
},
}
err := sc.Validate()
if err == nil {
t.Fatal("expected validation error for missing account_id")
}
}
func TestRunGetLiveAccountSnapshotValidationRejectsMissingAccountID(t *testing.T) {
sc := Scenario{
Name: "live_account_snap_missing_id",
Steps: []Step{
{
ID: "get",
Action: ActionGetLiveAccountSnapshot,
},
},
}
err := sc.Validate()
if err == nil {
t.Fatal("expected validation error for missing account_id")
}
}
func TestRunListLiveAuditEvents(t *testing.T) {
api := &fakeAPI{
listLiveAuditEventsResp: &altv1.ListLiveAuditEventsResponse{
Events: []*altv1.LiveAuditEvent{
{EventId: "aud-1", Type: "submit_confirmed", AccountId: "op-audit-test-001", OrderId: "lo-1"},
},
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "live_audit_query",
Steps: []Step{
{
ID: "list_audit",
Action: ActionListLiveAuditEvents,
Request: Request{AccountID: "op-audit-test-001"},
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, "audit_event_count=1") {
t.Errorf("output %q missing audit_event_count=1", out)
}
req := api.lastListLiveAuditEventsReq()
if req == nil {
t.Fatal("server did not receive ListLiveAuditEventsRequest")
}
if req.GetAccountId() != "op-audit-test-001" {
t.Errorf("account_id mismatch: %q", req.GetAccountId())
}
}
func TestLiveAuditQueryFixtureIsValid(t *testing.T) {
yamlPath := filepath.Join("..", "..", "testdata", "operator", "live_audit_query.yaml")
if _, err := LoadScenario(yamlPath); err != nil {
t.Fatalf("live_audit_query.yaml failed validation: %v", err)
}
expectedPath := filepath.Join("..", "..", "testdata", "operator", "expected", "live_audit_query.jsonl")
f, err := os.Open(expectedPath)
if err != nil {
t.Fatalf("open expected fixture: %v", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
sawAuditCount := 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["action"] == "list_live_audit_events" && rec["audit_event_count"] != nil {
sawAuditCount = true
}
if rec["type"] == "summary" {
sawSummary = true
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan expected fixture: %v", err)
}
if !sawAuditCount {
t.Error("expected fixture missing a list_live_audit_events line with audit_event_count")
}
if !sawSummary {
t.Error("expected fixture missing a summary line")
}
}
func TestRunListLiveAuditEventsValidationRejectsMissingAccountID(t *testing.T) {
sc := Scenario{
Name: "live_audit_missing_id",
Steps: []Step{
{
ID: "list",
Action: ActionListLiveAuditEvents,
},
},
}
err := sc.Validate()
if err == nil {
t.Fatal("expected validation error for missing account_id")
}
}
func TestEvaluateLiveAccountTypedError(t *testing.T) {
ev := StepEvent{Action: string(ActionSyncLiveAccount)}
step := Step{Expect: Expect{Status: "ok"}}
errInfo := &altv1.ErrorInfo{Code: "not_found", Message: "snapshot not found"}
ev2, code := evaluateLiveAccount(ev, step, errInfo, nil)
if code == codeOK {
t.Error("expected non-OK code for typed error")
}
if ev2.ErrorCode != "not_found" {
t.Errorf("error_code mismatch: %q", ev2.ErrorCode)
}
}
func TestEvaluateLiveAccountExpectedError(t *testing.T) {
ev := StepEvent{Action: string(ActionGetLiveAccountSnapshot)}
step := Step{Expect: Expect{Status: "error", ErrorCode: "not_found"}}
errInfo := &altv1.ErrorInfo{Code: "not_found", Message: "snapshot not found"}
ev2, code := evaluateLiveAccount(ev, step, errInfo, nil)
if code != codeOK {
t.Errorf("expected codeOK for matched typed error, got %d", code)
}
if ev2.Status != statusOK {
t.Errorf("expected status ok, got %q", ev2.Status)
}
}

View file

@ -64,31 +64,42 @@ const (
ActionGetLiveKillSwitch Action = "get_live_kill_switch"
// ActionSetLiveKillSwitch sets the live kill switch state (halt/resume).
ActionSetLiveKillSwitch Action = "set_live_kill_switch"
// ActionSyncLiveAccount fetches a fresh account snapshot from the broker and
// stores it in the worker.
ActionSyncLiveAccount Action = "sync_live_account"
// ActionGetLiveAccountSnapshot retrieves the last synced account snapshot for
// an operator alias account.
ActionGetLiveAccountSnapshot Action = "get_live_account_snapshot"
// ActionListLiveAuditEvents queries the durable live operation audit trail.
ActionListLiveAuditEvents Action = "list_live_audit_events"
)
// validActions is the closed set used for strict dry-run validation.
var validActions = map[Action]bool{
ActionHello: true,
ActionImportDailyBars: true,
ActionListInstruments: true,
ActionListBars: true,
ActionStartBacktest: true,
ActionListBacktestRuns: true,
ActionGetBacktestRunDetail: true,
ActionGetBacktestResult: true,
ActionCompareBacktestRuns: true,
ActionPollBacktestRun: true,
ActionStartPaperTrading: true,
ActionGetPaperTradingState: true,
ActionSubmitPaperOrder: true,
ActionCancelPaperOrder: true,
ActionFillPaperOrder: true,
ActionSubmitLiveOrder: true,
ActionCancelLiveOrder: true,
ActionGetLiveOrder: true,
ActionGetLiveRiskPolicy: true,
ActionGetLiveKillSwitch: true,
ActionSetLiveKillSwitch: true,
ActionHello: true,
ActionImportDailyBars: true,
ActionListInstruments: true,
ActionListBars: true,
ActionStartBacktest: true,
ActionListBacktestRuns: true,
ActionGetBacktestRunDetail: true,
ActionGetBacktestResult: true,
ActionCompareBacktestRuns: true,
ActionPollBacktestRun: true,
ActionStartPaperTrading: true,
ActionGetPaperTradingState: true,
ActionSubmitPaperOrder: true,
ActionCancelPaperOrder: true,
ActionFillPaperOrder: true,
ActionSubmitLiveOrder: true,
ActionCancelLiveOrder: true,
ActionGetLiveOrder: true,
ActionGetLiveRiskPolicy: true,
ActionGetLiveKillSwitch: true,
ActionSetLiveKillSwitch: true,
ActionSyncLiveAccount: true,
ActionGetLiveAccountSnapshot: true,
ActionListLiveAuditEvents: true,
}
// validOrderSides is the set of order side strings a submit_paper_order may use.
@ -296,6 +307,11 @@ type Request struct {
Halted bool `yaml:"halted"`
KillSwitchReason string `yaml:"kill_switch_reason"`
// Audit query fields. EventType filters list_live_audit_events by event type.
// Limit caps the number of returned audit events (0 = no limit).
EventType string `yaml:"event_type"`
Limit int `yaml:"limit"`
// PollingInterval configures how often the runner polls for updates.
PollingInterval Duration `yaml:"polling_interval"`
// PollingTimeout configures the maximum time to wait during polling.
@ -566,6 +582,18 @@ func validateRequest(step Step) error {
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: set_live_kill_switch requires request.account_id", step.ID)
}
case ActionSyncLiveAccount:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: sync_live_account requires request.account_id", step.ID)
}
case ActionGetLiveAccountSnapshot:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: get_live_account_snapshot requires request.account_id", step.ID)
}
case ActionListLiveAuditEvents:
if step.Request.AccountID == "" {
return fmt.Errorf("step %q: list_live_audit_events requires request.account_id", step.ID)
}
case ActionImportDailyBars:
if step.Request.Provider == "" {
return fmt.Errorf("step %q: import_daily_bars requires request.provider", step.ID)

View file

@ -0,0 +1,4 @@
{"action":"sync_live_account","cash_count":1,"live_account_id":"live-acct-1","live_broker":"kis","live_position_count":0,"live_stale":false,"scenario":"live_account_sync","status":"ok","step":"sync_account","synced_at_unix_ms":1749340800000,"type":"step"}
{"action":"get_live_account_snapshot","cash_count":1,"live_account_id":"live-acct-1","live_broker":"kis","live_position_count":0,"live_stale":false,"scenario":"live_account_sync","status":"ok","step":"get_snapshot","synced_at_unix_ms":1749340800000,"type":"step"}
{"action":"get_live_account_snapshot","error_code":"not_found","scenario":"live_account_sync","status":"ok","step":"get_snapshot_not_found","type":"step"}
{"exit_code":0,"passed":3,"scenario":"live_account_sync","status":"ok","steps":3,"type":"summary"}

View file

@ -0,0 +1,3 @@
{"action":"list_live_audit_events","audit_event_count":2,"scenario":"live_audit_query","status":"ok","step":"list_audit","type":"step"}
{"action":"list_live_audit_events","audit_event_count":1,"scenario":"live_audit_query","status":"ok","step":"list_audit_by_order","type":"step"}
{"exit_code":0,"passed":2,"scenario":"live_audit_query","status":"ok","steps":2,"type":"summary"}

View file

@ -41,6 +41,8 @@ ALT 운영 UI 게이트(`agent-ops/rules/project/rules.md`의 "운영 UI 구현
| `paper_order_lifecycle` | `alt operator scenario run --file testdata/operator/paper_order_lifecycle.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/paper_order_lifecycle.yaml` | `testdata/operator/expected/paper_order_lifecycle.jsonl` | `scenario`, `status`, `type`, `action`, `account_id`, `run_id`, `run_status`, `cash`, `equity_point_count`, `fill_count`, `latest_equity`, `position_count`, `risk`, `order_id`, `order_status`, `fill_price` | `0` (성공/terminal 도착), `1` (typed error/mismatch) | yes | paper order management list (주문 상태/체결가/취소) | `StartPaperTradingResponse.run.id`, `run_status`; `SubmitPaperOrderResponse.order.id`, `order_status`; `FillPaperOrderResponse.fill_price`, `order_status`; `CancelPaperOrderResponse.order_status`; `GetPaperTradingStateResponse.account_id`, `cash`, `fill_count`; summary `status`/`exit_code` | Flutter 주문 목록/상세 레이아웃 미정 |
| `live_order_lifecycle` | `alt operator scenario run --file testdata/operator/live_order_lifecycle.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/live_order_lifecycle.yaml` | `testdata/operator/expected/live_order_lifecycle.jsonl` | `scenario`, `status`, `type`, `action`, `live_order_id`, `live_order_status`, `broker_order_id`, `broker_status`, `operator_confirmed` | `0` (성공/operator gate 통과), `1` (typed error/confirmation missing/mismatch) | yes | live order management (실주문 상태/취소/브로커 상태) | `SubmitLiveOrderResponse.order.id`, `order.status`, `order.broker_id`, `order.broker_status`; `OperatorConfirmation.confirmed`; `CancelLiveOrderResponse.order.status`; `GetLiveOrderResponse.order.status`; summary `status`/`exit_code` | Flutter 실주문 목록/상세 레이아웃 미정 |
| `live_risk_kill_switch` | `alt operator scenario run --file testdata/operator/live_risk_kill_switch.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/live_risk_kill_switch.yaml` | `testdata/operator/expected/live_risk_kill_switch.jsonl` | `scenario`, `status`, `type`, `action`, `max_daily_orders`, `max_open_orders`, `allow_short_selling`, `kill_switch_halted`, `kill_switch_reason` | `0` (성공/정책 조회·킬스위치 해제), `1` (typed error/mismatch) | yes | live risk 제어 패널 (정책 표시·킬스위치 토글) | `GetLiveRiskPolicyResponse.policy.max_daily_orders`, `max_open_orders`, `allow_short_selling`; `GetLiveKillSwitchResponse.state.halted`, `state.reason`; `SetLiveKillSwitchResponse.state.halted`, `state.reason`; summary `status`/`exit_code` | Flutter 킬스위치 토글·정책 표시 레이아웃 미정 |
| `live_account_sync` | `alt operator scenario run --file testdata/operator/live_account_sync.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/live_account_sync.yaml` | `testdata/operator/expected/live_account_sync.jsonl` | `scenario`, `status`, `type`, `action`, `live_account_id`, `live_broker`, `cash_count`, `live_position_count`, `synced_at_unix_ms`, `live_stale` | `0` (sync 성공·snapshot 조회 성공·not_found 기대 충족), `1` (typed error/mismatch) | yes | live 계좌 잔고·포지션 스냅샷 (계좌 현황 패널) | `SyncLiveAccountResponse.snapshot.account_id`, `broker`, `cash[]`, `positions[]`, `synced_at_unix_ms`, `stale`; `GetLiveAccountSnapshotResponse.snapshot`; summary `status`/`exit_code` | Flutter 계좌 잔고·포지션 목록 레이아웃 미정 |
| `live_audit_query` | `alt operator scenario run --file testdata/operator/live_audit_query.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/live_audit_query.yaml` | `testdata/operator/expected/live_audit_query.jsonl` | `scenario`, `status`, `type`, `action`, `audit_event_count` | `0` (조회 성공·필터 조회 성공), `1` (typed error/mismatch) | yes | live 감사 이력 조회 (운영 감사 패널) | `ListLiveAuditEventsResponse.events[]`; `LiveAuditEvent.event_id`, `type`, `account_id`, `order_id`, `timestamp_unix_ms`; summary `status`/`exit_code` | Flutter 감사 이력 목록 레이아웃 미정 |
## Exit code 계약 보강 노트

View file

@ -0,0 +1,22 @@
name: live_account_sync
timeout: 10s
steps:
- id: sync_account
action: sync_live_account
request:
account_id: live-acct-1
expect:
status: ok
- id: get_snapshot
action: get_live_account_snapshot
request:
account_id: live-acct-1
expect:
status: ok
- id: get_snapshot_not_found
action: get_live_account_snapshot
request:
account_id: unknown-account
expect:
status: error
error_code: not_found

View file

@ -0,0 +1,18 @@
name: live_audit_query
timeout: 10s
steps:
- id: list_audit
action: list_live_audit_events
request:
account_id: op-audit-test-001
expect:
status: ok
- id: list_audit_by_order
action: list_live_audit_events
request:
account_id: op-audit-test-001
order_id: lo-1
event_type: submit_confirmed
limit: 10
expect:
status: ok

View file

@ -92,5 +92,19 @@ Map<String, GeneratedMessage Function(List<int>)> altParserMap() {
LiveKillSwitchState.fromBuffer,
LiveRiskDecision.getDefault().info_.qualifiedMessageName:
LiveRiskDecision.fromBuffer,
SyncLiveAccountRequest.getDefault().info_.qualifiedMessageName:
SyncLiveAccountRequest.fromBuffer,
SyncLiveAccountResponse.getDefault().info_.qualifiedMessageName:
SyncLiveAccountResponse.fromBuffer,
GetLiveAccountSnapshotRequest.getDefault().info_.qualifiedMessageName:
GetLiveAccountSnapshotRequest.fromBuffer,
GetLiveAccountSnapshotResponse.getDefault().info_.qualifiedMessageName:
GetLiveAccountSnapshotResponse.fromBuffer,
LiveAccountSnapshot.getDefault().info_.qualifiedMessageName:
LiveAccountSnapshot.fromBuffer,
LiveCashBalance.getDefault().info_.qualifiedMessageName:
LiveCashBalance.fromBuffer,
LivePositionSnapshot.getDefault().info_.qualifiedMessageName:
LivePositionSnapshot.fromBuffer,
};
}

View file

@ -696,24 +696,96 @@ class LiveOrder extends $pb.GeneratedMessage {
void clearUpdatedAtUnixMs() => $_clearField(16);
}
/// LiveAccountSnapshot carries live account state from a broker.
class LiveAccountSnapshot extends $pb.GeneratedMessage {
factory LiveAccountSnapshot({
$core.String? broker,
$core.String? accountId,
$0.Decimal? cash,
$0.Decimal? totalEquity,
$0.Decimal? availableMargin,
$fixnum.Int64? snapshotTimeUnixMs,
/// LiveCashBalance is a single currency-denominated cash entry in a live account.
/// Raw account numbers and provider-specific identifiers are never included.
class LiveCashBalance extends $pb.GeneratedMessage {
factory LiveCashBalance({
$0.Currency? currency,
$0.Decimal? amount,
}) {
final result = create();
if (currency != null) result.currency = currency;
if (amount != null) result.amount = amount;
return result;
}
LiveCashBalance._();
factory LiveCashBalance.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory LiveCashBalance.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'LiveCashBalance',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aE<$0.Currency>(1, _omitFieldNames ? '' : 'currency',
enumValues: $0.Currency.values)
..aOM<$0.Decimal>(2, _omitFieldNames ? '' : 'amount',
subBuilder: $0.Decimal.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
LiveCashBalance clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
LiveCashBalance copyWith(void Function(LiveCashBalance) updates) =>
super.copyWith((message) => updates(message as LiveCashBalance))
as LiveCashBalance;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static LiveCashBalance create() => LiveCashBalance._();
@$core.override
LiveCashBalance createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static LiveCashBalance getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<LiveCashBalance>(create);
static LiveCashBalance? _defaultInstance;
@$pb.TagNumber(1)
$0.Currency get currency => $_getN(0);
@$pb.TagNumber(1)
set currency($0.Currency value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasCurrency() => $_has(0);
@$pb.TagNumber(1)
void clearCurrency() => $_clearField(1);
@$pb.TagNumber(2)
$0.Decimal get amount => $_getN(1);
@$pb.TagNumber(2)
set amount($0.Decimal value) => $_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasAmount() => $_has(1);
@$pb.TagNumber(2)
void clearAmount() => $_clearField(2);
@$pb.TagNumber(2)
$0.Decimal ensureAmount() => $_ensure(1);
}
/// LiveAccountSnapshot carries sanitized live account state from a broker.
/// Raw account numbers and provider payload are never exposed.
class LiveAccountSnapshot extends $pb.GeneratedMessage {
factory LiveAccountSnapshot({
$core.String? accountId,
$core.String? broker,
$core.Iterable<LiveCashBalance>? cash,
$core.Iterable<LivePositionSnapshot>? positions,
$fixnum.Int64? syncedAtUnixMs,
$core.bool? stale,
}) {
final result = create();
if (broker != null) result.broker = broker;
if (accountId != null) result.accountId = accountId;
if (cash != null) result.cash = cash;
if (totalEquity != null) result.totalEquity = totalEquity;
if (availableMargin != null) result.availableMargin = availableMargin;
if (snapshotTimeUnixMs != null)
result.snapshotTimeUnixMs = snapshotTimeUnixMs;
if (broker != null) result.broker = broker;
if (cash != null) result.cash.addAll(cash);
if (positions != null) result.positions.addAll(positions);
if (syncedAtUnixMs != null) result.syncedAtUnixMs = syncedAtUnixMs;
if (stale != null) result.stale = stale;
return result;
}
@ -730,15 +802,14 @@ class LiveAccountSnapshot extends $pb.GeneratedMessage {
_omitMessageNames ? '' : 'LiveAccountSnapshot',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'broker')
..aOS(2, _omitFieldNames ? '' : 'accountId')
..aOM<$0.Decimal>(3, _omitFieldNames ? '' : 'cash',
subBuilder: $0.Decimal.create)
..aOM<$0.Decimal>(4, _omitFieldNames ? '' : 'totalEquity',
subBuilder: $0.Decimal.create)
..aOM<$0.Decimal>(5, _omitFieldNames ? '' : 'availableMargin',
subBuilder: $0.Decimal.create)
..aInt64(6, _omitFieldNames ? '' : 'snapshotTimeUnixMs')
..aOS(1, _omitFieldNames ? '' : 'accountId')
..aOS(2, _omitFieldNames ? '' : 'broker')
..pPM<LiveCashBalance>(3, _omitFieldNames ? '' : 'cash',
subBuilder: LiveCashBalance.create)
..pPM<LivePositionSnapshot>(4, _omitFieldNames ? '' : 'positions',
subBuilder: LivePositionSnapshot.create)
..aInt64(5, _omitFieldNames ? '' : 'syncedAtUnixMs')
..aOB(6, _omitFieldNames ? '' : 'stale')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -761,64 +832,310 @@ class LiveAccountSnapshot extends $pb.GeneratedMessage {
static LiveAccountSnapshot? _defaultInstance;
@$pb.TagNumber(1)
$core.String get broker => $_getSZ(0);
$core.String get accountId => $_getSZ(0);
@$pb.TagNumber(1)
set broker($core.String value) => $_setString(0, value);
set accountId($core.String value) => $_setString(0, value);
@$pb.TagNumber(1)
$core.bool hasBroker() => $_has(0);
$core.bool hasAccountId() => $_has(0);
@$pb.TagNumber(1)
void clearBroker() => $_clearField(1);
void clearAccountId() => $_clearField(1);
@$pb.TagNumber(2)
$core.String get accountId => $_getSZ(1);
$core.String get broker => $_getSZ(1);
@$pb.TagNumber(2)
set accountId($core.String value) => $_setString(1, value);
set broker($core.String value) => $_setString(1, value);
@$pb.TagNumber(2)
$core.bool hasAccountId() => $_has(1);
$core.bool hasBroker() => $_has(1);
@$pb.TagNumber(2)
void clearAccountId() => $_clearField(2);
void clearBroker() => $_clearField(2);
@$pb.TagNumber(3)
$0.Decimal get cash => $_getN(2);
@$pb.TagNumber(3)
set cash($0.Decimal value) => $_setField(3, value);
@$pb.TagNumber(3)
$core.bool hasCash() => $_has(2);
@$pb.TagNumber(3)
void clearCash() => $_clearField(3);
@$pb.TagNumber(3)
$0.Decimal ensureCash() => $_ensure(2);
$pb.PbList<LiveCashBalance> get cash => $_getList(2);
@$pb.TagNumber(4)
$0.Decimal get totalEquity => $_getN(3);
@$pb.TagNumber(4)
set totalEquity($0.Decimal value) => $_setField(4, value);
@$pb.TagNumber(4)
$core.bool hasTotalEquity() => $_has(3);
@$pb.TagNumber(4)
void clearTotalEquity() => $_clearField(4);
@$pb.TagNumber(4)
$0.Decimal ensureTotalEquity() => $_ensure(3);
$pb.PbList<LivePositionSnapshot> get positions => $_getList(3);
@$pb.TagNumber(5)
$0.Decimal get availableMargin => $_getN(4);
$fixnum.Int64 get syncedAtUnixMs => $_getI64(4);
@$pb.TagNumber(5)
set availableMargin($0.Decimal value) => $_setField(5, value);
set syncedAtUnixMs($fixnum.Int64 value) => $_setInt64(4, value);
@$pb.TagNumber(5)
$core.bool hasAvailableMargin() => $_has(4);
$core.bool hasSyncedAtUnixMs() => $_has(4);
@$pb.TagNumber(5)
void clearAvailableMargin() => $_clearField(5);
@$pb.TagNumber(5)
$0.Decimal ensureAvailableMargin() => $_ensure(4);
void clearSyncedAtUnixMs() => $_clearField(5);
@$pb.TagNumber(6)
$fixnum.Int64 get snapshotTimeUnixMs => $_getI64(5);
$core.bool get stale => $_getBF(5);
@$pb.TagNumber(6)
set snapshotTimeUnixMs($fixnum.Int64 value) => $_setInt64(5, value);
set stale($core.bool value) => $_setBool(5, value);
@$pb.TagNumber(6)
$core.bool hasSnapshotTimeUnixMs() => $_has(5);
$core.bool hasStale() => $_has(5);
@$pb.TagNumber(6)
void clearSnapshotTimeUnixMs() => $_clearField(6);
void clearStale() => $_clearField(6);
}
/// SyncLiveAccountRequest triggers a fresh account snapshot fetch from the broker.
class SyncLiveAccountRequest extends $pb.GeneratedMessage {
factory SyncLiveAccountRequest({
$core.String? accountId,
}) {
final result = create();
if (accountId != null) result.accountId = accountId;
return result;
}
SyncLiveAccountRequest._();
factory SyncLiveAccountRequest.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory SyncLiveAccountRequest.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'SyncLiveAccountRequest',
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.')
SyncLiveAccountRequest clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SyncLiveAccountRequest copyWith(
void Function(SyncLiveAccountRequest) updates) =>
super.copyWith((message) => updates(message as SyncLiveAccountRequest))
as SyncLiveAccountRequest;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static SyncLiveAccountRequest create() => SyncLiveAccountRequest._();
@$core.override
SyncLiveAccountRequest createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static SyncLiveAccountRequest getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<SyncLiveAccountRequest>(create);
static SyncLiveAccountRequest? _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);
}
/// SyncLiveAccountResponse returns the refreshed snapshot or a typed error.
class SyncLiveAccountResponse extends $pb.GeneratedMessage {
factory SyncLiveAccountResponse({
LiveAccountSnapshot? snapshot,
$0.ErrorInfo? error,
}) {
final result = create();
if (snapshot != null) result.snapshot = snapshot;
if (error != null) result.error = error;
return result;
}
SyncLiveAccountResponse._();
factory SyncLiveAccountResponse.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory SyncLiveAccountResponse.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'SyncLiveAccountResponse',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOM<LiveAccountSnapshot>(1, _omitFieldNames ? '' : 'snapshot',
subBuilder: LiveAccountSnapshot.create)
..aOM<$0.ErrorInfo>(2, _omitFieldNames ? '' : 'error',
subBuilder: $0.ErrorInfo.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SyncLiveAccountResponse clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
SyncLiveAccountResponse copyWith(
void Function(SyncLiveAccountResponse) updates) =>
super.copyWith((message) => updates(message as SyncLiveAccountResponse))
as SyncLiveAccountResponse;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static SyncLiveAccountResponse create() => SyncLiveAccountResponse._();
@$core.override
SyncLiveAccountResponse createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static SyncLiveAccountResponse getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<SyncLiveAccountResponse>(create);
static SyncLiveAccountResponse? _defaultInstance;
@$pb.TagNumber(1)
LiveAccountSnapshot get snapshot => $_getN(0);
@$pb.TagNumber(1)
set snapshot(LiveAccountSnapshot value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasSnapshot() => $_has(0);
@$pb.TagNumber(1)
void clearSnapshot() => $_clearField(1);
@$pb.TagNumber(1)
LiveAccountSnapshot ensureSnapshot() => $_ensure(0);
@$pb.TagNumber(2)
$0.ErrorInfo get error => $_getN(1);
@$pb.TagNumber(2)
set error($0.ErrorInfo value) => $_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasError() => $_has(1);
@$pb.TagNumber(2)
void clearError() => $_clearField(2);
@$pb.TagNumber(2)
$0.ErrorInfo ensureError() => $_ensure(1);
}
/// GetLiveAccountSnapshotRequest retrieves the last synced account snapshot.
class GetLiveAccountSnapshotRequest extends $pb.GeneratedMessage {
factory GetLiveAccountSnapshotRequest({
$core.String? accountId,
}) {
final result = create();
if (accountId != null) result.accountId = accountId;
return result;
}
GetLiveAccountSnapshotRequest._();
factory GetLiveAccountSnapshotRequest.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory GetLiveAccountSnapshotRequest.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'GetLiveAccountSnapshotRequest',
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.')
GetLiveAccountSnapshotRequest clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
GetLiveAccountSnapshotRequest copyWith(
void Function(GetLiveAccountSnapshotRequest) updates) =>
super.copyWith(
(message) => updates(message as GetLiveAccountSnapshotRequest))
as GetLiveAccountSnapshotRequest;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static GetLiveAccountSnapshotRequest create() =>
GetLiveAccountSnapshotRequest._();
@$core.override
GetLiveAccountSnapshotRequest createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static GetLiveAccountSnapshotRequest getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<GetLiveAccountSnapshotRequest>(create);
static GetLiveAccountSnapshotRequest? _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);
}
/// GetLiveAccountSnapshotResponse returns the cached snapshot or a typed error.
class GetLiveAccountSnapshotResponse extends $pb.GeneratedMessage {
factory GetLiveAccountSnapshotResponse({
LiveAccountSnapshot? snapshot,
$0.ErrorInfo? error,
}) {
final result = create();
if (snapshot != null) result.snapshot = snapshot;
if (error != null) result.error = error;
return result;
}
GetLiveAccountSnapshotResponse._();
factory GetLiveAccountSnapshotResponse.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory GetLiveAccountSnapshotResponse.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'GetLiveAccountSnapshotResponse',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOM<LiveAccountSnapshot>(1, _omitFieldNames ? '' : 'snapshot',
subBuilder: LiveAccountSnapshot.create)
..aOM<$0.ErrorInfo>(2, _omitFieldNames ? '' : 'error',
subBuilder: $0.ErrorInfo.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
GetLiveAccountSnapshotResponse clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
GetLiveAccountSnapshotResponse copyWith(
void Function(GetLiveAccountSnapshotResponse) updates) =>
super.copyWith(
(message) => updates(message as GetLiveAccountSnapshotResponse))
as GetLiveAccountSnapshotResponse;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static GetLiveAccountSnapshotResponse create() =>
GetLiveAccountSnapshotResponse._();
@$core.override
GetLiveAccountSnapshotResponse createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static GetLiveAccountSnapshotResponse getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<GetLiveAccountSnapshotResponse>(create);
static GetLiveAccountSnapshotResponse? _defaultInstance;
@$pb.TagNumber(1)
LiveAccountSnapshot get snapshot => $_getN(0);
@$pb.TagNumber(1)
set snapshot(LiveAccountSnapshot value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasSnapshot() => $_has(0);
@$pb.TagNumber(1)
void clearSnapshot() => $_clearField(1);
@$pb.TagNumber(1)
LiveAccountSnapshot ensureSnapshot() => $_ensure(0);
@$pb.TagNumber(2)
$0.ErrorInfo get error => $_getN(1);
@$pb.TagNumber(2)
set error($0.ErrorInfo value) => $_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasError() => $_has(1);
@$pb.TagNumber(2)
void clearError() => $_clearField(2);
@$pb.TagNumber(2)
$0.ErrorInfo ensureError() => $_ensure(1);
}
/// LivePositionSnapshot is a single position inside a live account.
@ -963,21 +1280,34 @@ class LivePositionSnapshot extends $pb.GeneratedMessage {
$0.Decimal ensurePnl() => $_ensure(6);
}
/// LiveAuditEvent is a broker-neutral event record for audit trails.
/// LiveAuditEvent is a durable, broker-neutral record of a live operation event.
/// Payload must never carry raw tokens, account numbers, or provider bodies.
class LiveAuditEvent extends $pb.GeneratedMessage {
factory LiveAuditEvent({
$core.String? type,
$fixnum.Int64? timestampUnixMs,
$core.String? broker,
$core.String? accountId,
$core.Iterable<$core.MapEntry<$core.String, $core.String>>? detail,
$core.Iterable<$core.MapEntry<$core.String, $core.String>>? payload,
$core.String? eventId,
$core.String? orderId,
$core.String? status,
$core.String? reason,
$core.String? actor,
$core.String? correlationId,
}) {
final result = create();
if (type != null) result.type = type;
if (timestampUnixMs != null) result.timestampUnixMs = timestampUnixMs;
if (broker != null) result.broker = broker;
if (accountId != null) result.accountId = accountId;
if (detail != null) result.detail.addEntries(detail);
if (payload != null) result.payload.addEntries(payload);
if (eventId != null) result.eventId = eventId;
if (orderId != null) result.orderId = orderId;
if (status != null) result.status = status;
if (reason != null) result.reason = reason;
if (actor != null) result.actor = actor;
if (correlationId != null) result.correlationId = correlationId;
return result;
}
@ -998,11 +1328,17 @@ class LiveAuditEvent extends $pb.GeneratedMessage {
..aInt64(2, _omitFieldNames ? '' : 'timestampUnixMs')
..aOS(3, _omitFieldNames ? '' : 'broker')
..aOS(4, _omitFieldNames ? '' : 'accountId')
..m<$core.String, $core.String>(5, _omitFieldNames ? '' : 'detail',
entryClassName: 'LiveAuditEvent.DetailEntry',
..m<$core.String, $core.String>(5, _omitFieldNames ? '' : 'payload',
entryClassName: 'LiveAuditEvent.PayloadEntry',
keyFieldType: $pb.PbFieldType.OS,
valueFieldType: $pb.PbFieldType.OS,
packageName: const $pb.PackageName('alt.v1'))
..aOS(6, _omitFieldNames ? '' : 'eventId')
..aOS(7, _omitFieldNames ? '' : 'orderId')
..aOS(8, _omitFieldNames ? '' : 'status')
..aOS(9, _omitFieldNames ? '' : 'reason')
..aOS(10, _omitFieldNames ? '' : 'actor')
..aOS(11, _omitFieldNames ? '' : 'correlationId')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -1061,7 +1397,224 @@ class LiveAuditEvent extends $pb.GeneratedMessage {
void clearAccountId() => $_clearField(4);
@$pb.TagNumber(5)
$pb.PbMap<$core.String, $core.String> get detail => $_getMap(4);
$pb.PbMap<$core.String, $core.String> get payload => $_getMap(4);
@$pb.TagNumber(6)
$core.String get eventId => $_getSZ(5);
@$pb.TagNumber(6)
set eventId($core.String value) => $_setString(5, value);
@$pb.TagNumber(6)
$core.bool hasEventId() => $_has(5);
@$pb.TagNumber(6)
void clearEventId() => $_clearField(6);
@$pb.TagNumber(7)
$core.String get orderId => $_getSZ(6);
@$pb.TagNumber(7)
set orderId($core.String value) => $_setString(6, value);
@$pb.TagNumber(7)
$core.bool hasOrderId() => $_has(6);
@$pb.TagNumber(7)
void clearOrderId() => $_clearField(7);
@$pb.TagNumber(8)
$core.String get status => $_getSZ(7);
@$pb.TagNumber(8)
set status($core.String value) => $_setString(7, value);
@$pb.TagNumber(8)
$core.bool hasStatus() => $_has(7);
@$pb.TagNumber(8)
void clearStatus() => $_clearField(8);
@$pb.TagNumber(9)
$core.String get reason => $_getSZ(8);
@$pb.TagNumber(9)
set reason($core.String value) => $_setString(8, value);
@$pb.TagNumber(9)
$core.bool hasReason() => $_has(8);
@$pb.TagNumber(9)
void clearReason() => $_clearField(9);
@$pb.TagNumber(10)
$core.String get actor => $_getSZ(9);
@$pb.TagNumber(10)
set actor($core.String value) => $_setString(9, value);
@$pb.TagNumber(10)
$core.bool hasActor() => $_has(9);
@$pb.TagNumber(10)
void clearActor() => $_clearField(10);
@$pb.TagNumber(11)
$core.String get correlationId => $_getSZ(10);
@$pb.TagNumber(11)
set correlationId($core.String value) => $_setString(10, value);
@$pb.TagNumber(11)
$core.bool hasCorrelationId() => $_has(10);
@$pb.TagNumber(11)
void clearCorrelationId() => $_clearField(11);
}
/// ListLiveAuditEventsRequest queries the durable audit trail with optional
/// filters by account, order, and event type.
class ListLiveAuditEventsRequest extends $pb.GeneratedMessage {
factory ListLiveAuditEventsRequest({
$core.String? accountId,
$core.String? orderId,
$core.String? eventType,
$core.int? limit,
}) {
final result = create();
if (accountId != null) result.accountId = accountId;
if (orderId != null) result.orderId = orderId;
if (eventType != null) result.eventType = eventType;
if (limit != null) result.limit = limit;
return result;
}
ListLiveAuditEventsRequest._();
factory ListLiveAuditEventsRequest.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory ListLiveAuditEventsRequest.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'ListLiveAuditEventsRequest',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'accountId')
..aOS(2, _omitFieldNames ? '' : 'orderId')
..aOS(3, _omitFieldNames ? '' : 'eventType')
..aI(4, _omitFieldNames ? '' : 'limit')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
ListLiveAuditEventsRequest clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
ListLiveAuditEventsRequest copyWith(
void Function(ListLiveAuditEventsRequest) updates) =>
super.copyWith(
(message) => updates(message as ListLiveAuditEventsRequest))
as ListLiveAuditEventsRequest;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ListLiveAuditEventsRequest create() => ListLiveAuditEventsRequest._();
@$core.override
ListLiveAuditEventsRequest createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static ListLiveAuditEventsRequest getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<ListLiveAuditEventsRequest>(create);
static ListLiveAuditEventsRequest? _defaultInstance;
@$pb.TagNumber(1)
$core.String get accountId => $_getSZ(0);
@$pb.TagNumber(1)
set accountId($core.String value) => $_setString(0, value);
@$pb.TagNumber(1)
$core.bool hasAccountId() => $_has(0);
@$pb.TagNumber(1)
void clearAccountId() => $_clearField(1);
@$pb.TagNumber(2)
$core.String get orderId => $_getSZ(1);
@$pb.TagNumber(2)
set orderId($core.String value) => $_setString(1, value);
@$pb.TagNumber(2)
$core.bool hasOrderId() => $_has(1);
@$pb.TagNumber(2)
void clearOrderId() => $_clearField(2);
@$pb.TagNumber(3)
$core.String get eventType => $_getSZ(2);
@$pb.TagNumber(3)
set eventType($core.String value) => $_setString(2, value);
@$pb.TagNumber(3)
$core.bool hasEventType() => $_has(2);
@$pb.TagNumber(3)
void clearEventType() => $_clearField(3);
@$pb.TagNumber(4)
$core.int get limit => $_getIZ(3);
@$pb.TagNumber(4)
set limit($core.int value) => $_setSignedInt32(3, value);
@$pb.TagNumber(4)
$core.bool hasLimit() => $_has(3);
@$pb.TagNumber(4)
void clearLimit() => $_clearField(4);
}
/// ListLiveAuditEventsResponse returns the matching audit event records or a
/// typed error.
class ListLiveAuditEventsResponse extends $pb.GeneratedMessage {
factory ListLiveAuditEventsResponse({
$core.Iterable<LiveAuditEvent>? events,
$0.ErrorInfo? error,
}) {
final result = create();
if (events != null) result.events.addAll(events);
if (error != null) result.error = error;
return result;
}
ListLiveAuditEventsResponse._();
factory ListLiveAuditEventsResponse.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory ListLiveAuditEventsResponse.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'ListLiveAuditEventsResponse',
package: const $pb.PackageName(_omitMessageNames ? '' : 'alt.v1'),
createEmptyInstance: create)
..pPM<LiveAuditEvent>(1, _omitFieldNames ? '' : 'events',
subBuilder: LiveAuditEvent.create)
..aOM<$0.ErrorInfo>(2, _omitFieldNames ? '' : 'error',
subBuilder: $0.ErrorInfo.create)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
ListLiveAuditEventsResponse clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
ListLiveAuditEventsResponse copyWith(
void Function(ListLiveAuditEventsResponse) updates) =>
super.copyWith(
(message) => updates(message as ListLiveAuditEventsResponse))
as ListLiveAuditEventsResponse;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static ListLiveAuditEventsResponse create() =>
ListLiveAuditEventsResponse._();
@$core.override
ListLiveAuditEventsResponse createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static ListLiveAuditEventsResponse getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<ListLiveAuditEventsResponse>(create);
static ListLiveAuditEventsResponse? _defaultInstance;
@$pb.TagNumber(1)
$pb.PbList<LiveAuditEvent> get events => $_getList(0);
@$pb.TagNumber(2)
$0.ErrorInfo get error => $_getN(1);
@$pb.TagNumber(2)
set error($0.ErrorInfo value) => $_setField(2, value);
@$pb.TagNumber(2)
$core.bool hasError() => $_has(1);
@$pb.TagNumber(2)
void clearError() => $_clearField(2);
@$pb.TagNumber(2)
$0.ErrorInfo ensureError() => $_ensure(1);
}
/// OperatorConfirmation is the explicit operator gate required before any live

View file

@ -239,53 +239,155 @@ final $typed_data.Uint8List liveOrderDescriptor = $convert.base64Decode(
'9jcmVhdGVkQXRVbml4TXMSKwoSdXBkYXRlZF9hdF91bml4X21zGBAgASgDUg91cGRhdGVkQXRV'
'bml4TXM=');
@$core.Deprecated('Use liveCashBalanceDescriptor instead')
const LiveCashBalance$json = {
'1': 'LiveCashBalance',
'2': [
{
'1': 'currency',
'3': 1,
'4': 1,
'5': 14,
'6': '.alt.v1.Currency',
'10': 'currency'
},
{
'1': 'amount',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.Decimal',
'10': 'amount'
},
],
};
/// Descriptor for `LiveCashBalance`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List liveCashBalanceDescriptor = $convert.base64Decode(
'Cg9MaXZlQ2FzaEJhbGFuY2USLAoIY3VycmVuY3kYASABKA4yEC5hbHQudjEuQ3VycmVuY3lSCG'
'N1cnJlbmN5EicKBmFtb3VudBgCIAEoCzIPLmFsdC52MS5EZWNpbWFsUgZhbW91bnQ=');
@$core.Deprecated('Use liveAccountSnapshotDescriptor instead')
const LiveAccountSnapshot$json = {
'1': 'LiveAccountSnapshot',
'2': [
{'1': 'broker', '3': 1, '4': 1, '5': 9, '10': 'broker'},
{'1': 'account_id', '3': 2, '4': 1, '5': 9, '10': 'accountId'},
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
{'1': 'broker', '3': 2, '4': 1, '5': 9, '10': 'broker'},
{
'1': 'cash',
'3': 3,
'4': 1,
'4': 3,
'5': 11,
'6': '.alt.v1.Decimal',
'6': '.alt.v1.LiveCashBalance',
'10': 'cash'
},
{
'1': 'total_equity',
'1': 'positions',
'3': 4,
'4': 1,
'4': 3,
'5': 11,
'6': '.alt.v1.Decimal',
'10': 'totalEquity'
},
{
'1': 'available_margin',
'3': 5,
'4': 1,
'5': 11,
'6': '.alt.v1.Decimal',
'10': 'availableMargin'
},
{
'1': 'snapshot_time_unix_ms',
'3': 6,
'4': 1,
'5': 3,
'10': 'snapshotTimeUnixMs'
'6': '.alt.v1.LivePositionSnapshot',
'10': 'positions'
},
{'1': 'synced_at_unix_ms', '3': 5, '4': 1, '5': 3, '10': 'syncedAtUnixMs'},
{'1': 'stale', '3': 6, '4': 1, '5': 8, '10': 'stale'},
],
};
/// Descriptor for `LiveAccountSnapshot`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List liveAccountSnapshotDescriptor = $convert.base64Decode(
'ChNMaXZlQWNjb3VudFNuYXBzaG90EhYKBmJyb2tlchgBIAEoCVIGYnJva2VyEh0KCmFjY291bn'
'RfaWQYAiABKAlSCWFjY291bnRJZBIjCgRjYXNoGAMgASgLMg8uYWx0LnYxLkRlY2ltYWxSBGNh'
'c2gSMgoMdG90YWxfZXF1aXR5GAQgASgLMg8uYWx0LnYxLkRlY2ltYWxSC3RvdGFsRXF1aXR5Ej'
'oKEGF2YWlsYWJsZV9tYXJnaW4YBSABKAsyDy5hbHQudjEuRGVjaW1hbFIPYXZhaWxhYmxlTWFy'
'Z2luEjEKFXNuYXBzaG90X3RpbWVfdW5peF9tcxgGIAEoA1ISc25hcHNob3RUaW1lVW5peE1z');
'ChNMaXZlQWNjb3VudFNuYXBzaG90Eh0KCmFjY291bnRfaWQYASABKAlSCWFjY291bnRJZBIWCg'
'Zicm9rZXIYAiABKAlSBmJyb2tlchIrCgRjYXNoGAMgAygLMhcuYWx0LnYxLkxpdmVDYXNoQmFs'
'YW5jZVIEY2FzaBI6Cglwb3NpdGlvbnMYBCADKAsyHC5hbHQudjEuTGl2ZVBvc2l0aW9uU25hcH'
'Nob3RSCXBvc2l0aW9ucxIpChFzeW5jZWRfYXRfdW5peF9tcxgFIAEoA1IOc3luY2VkQXRVbml4'
'TXMSFAoFc3RhbGUYBiABKAhSBXN0YWxl');
@$core.Deprecated('Use syncLiveAccountRequestDescriptor instead')
const SyncLiveAccountRequest$json = {
'1': 'SyncLiveAccountRequest',
'2': [
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
],
};
/// Descriptor for `SyncLiveAccountRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List syncLiveAccountRequestDescriptor =
$convert.base64Decode(
'ChZTeW5jTGl2ZUFjY291bnRSZXF1ZXN0Eh0KCmFjY291bnRfaWQYASABKAlSCWFjY291bnRJZA'
'==');
@$core.Deprecated('Use syncLiveAccountResponseDescriptor instead')
const SyncLiveAccountResponse$json = {
'1': 'SyncLiveAccountResponse',
'2': [
{
'1': 'snapshot',
'3': 1,
'4': 1,
'5': 11,
'6': '.alt.v1.LiveAccountSnapshot',
'10': 'snapshot'
},
{
'1': 'error',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.ErrorInfo',
'10': 'error'
},
],
};
/// Descriptor for `SyncLiveAccountResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List syncLiveAccountResponseDescriptor = $convert.base64Decode(
'ChdTeW5jTGl2ZUFjY291bnRSZXNwb25zZRI3CghzbmFwc2hvdBgBIAEoCzIbLmFsdC52MS5MaX'
'ZlQWNjb3VudFNuYXBzaG90UghzbmFwc2hvdBInCgVlcnJvchgCIAEoCzIRLmFsdC52MS5FcnJv'
'ckluZm9SBWVycm9y');
@$core.Deprecated('Use getLiveAccountSnapshotRequestDescriptor instead')
const GetLiveAccountSnapshotRequest$json = {
'1': 'GetLiveAccountSnapshotRequest',
'2': [
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
],
};
/// Descriptor for `GetLiveAccountSnapshotRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List getLiveAccountSnapshotRequestDescriptor =
$convert.base64Decode(
'Ch1HZXRMaXZlQWNjb3VudFNuYXBzaG90UmVxdWVzdBIdCgphY2NvdW50X2lkGAEgASgJUglhY2'
'NvdW50SWQ=');
@$core.Deprecated('Use getLiveAccountSnapshotResponseDescriptor instead')
const GetLiveAccountSnapshotResponse$json = {
'1': 'GetLiveAccountSnapshotResponse',
'2': [
{
'1': 'snapshot',
'3': 1,
'4': 1,
'5': 11,
'6': '.alt.v1.LiveAccountSnapshot',
'10': 'snapshot'
},
{
'1': 'error',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.ErrorInfo',
'10': 'error'
},
],
};
/// Descriptor for `GetLiveAccountSnapshotResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List getLiveAccountSnapshotResponseDescriptor =
$convert.base64Decode(
'Ch5HZXRMaXZlQWNjb3VudFNuYXBzaG90UmVzcG9uc2USNwoIc25hcHNob3QYASABKAsyGy5hbH'
'QudjEuTGl2ZUFjY291bnRTbmFwc2hvdFIIc25hcHNob3QSJwoFZXJyb3IYAiABKAsyES5hbHQu'
'djEuRXJyb3JJbmZvUgVlcnJvcg==');
@$core.Deprecated('Use livePositionSnapshotDescriptor instead')
const LivePositionSnapshot$json = {
@ -347,20 +449,26 @@ const LiveAuditEvent$json = {
{'1': 'broker', '3': 3, '4': 1, '5': 9, '10': 'broker'},
{'1': 'account_id', '3': 4, '4': 1, '5': 9, '10': 'accountId'},
{
'1': 'detail',
'1': 'payload',
'3': 5,
'4': 3,
'5': 11,
'6': '.alt.v1.LiveAuditEvent.DetailEntry',
'10': 'detail'
'6': '.alt.v1.LiveAuditEvent.PayloadEntry',
'10': 'payload'
},
{'1': 'event_id', '3': 6, '4': 1, '5': 9, '10': 'eventId'},
{'1': 'order_id', '3': 7, '4': 1, '5': 9, '10': 'orderId'},
{'1': 'status', '3': 8, '4': 1, '5': 9, '10': 'status'},
{'1': 'reason', '3': 9, '4': 1, '5': 9, '10': 'reason'},
{'1': 'actor', '3': 10, '4': 1, '5': 9, '10': 'actor'},
{'1': 'correlation_id', '3': 11, '4': 1, '5': 9, '10': 'correlationId'},
],
'3': [LiveAuditEvent_DetailEntry$json],
'3': [LiveAuditEvent_PayloadEntry$json],
};
@$core.Deprecated('Use liveAuditEventDescriptor instead')
const LiveAuditEvent_DetailEntry$json = {
'1': 'DetailEntry',
const LiveAuditEvent_PayloadEntry$json = {
'1': 'PayloadEntry',
'2': [
{'1': 'key', '3': 1, '4': 1, '5': 9, '10': 'key'},
{'1': 'value', '3': 2, '4': 1, '5': 9, '10': 'value'},
@ -372,9 +480,60 @@ const LiveAuditEvent_DetailEntry$json = {
final $typed_data.Uint8List liveAuditEventDescriptor = $convert.base64Decode(
'Cg5MaXZlQXVkaXRFdmVudBISCgR0eXBlGAEgASgJUgR0eXBlEioKEXRpbWVzdGFtcF91bml4X2'
'1zGAIgASgDUg90aW1lc3RhbXBVbml4TXMSFgoGYnJva2VyGAMgASgJUgZicm9rZXISHQoKYWNj'
'b3VudF9pZBgEIAEoCVIJYWNjb3VudElkEjoKBmRldGFpbBgFIAMoCzIiLmFsdC52MS5MaXZlQX'
'VkaXRFdmVudC5EZXRhaWxFbnRyeVIGZGV0YWlsGjkKC0RldGFpbEVudHJ5EhAKA2tleRgBIAEo'
'CVIDa2V5EhQKBXZhbHVlGAIgASgJUgV2YWx1ZToCOAE=');
'b3VudF9pZBgEIAEoCVIJYWNjb3VudElkEj0KB3BheWxvYWQYBSADKAsyIy5hbHQudjEuTGl2ZU'
'F1ZGl0RXZlbnQuUGF5bG9hZEVudHJ5UgdwYXlsb2FkEhkKCGV2ZW50X2lkGAYgASgJUgdldmVu'
'dElkEhkKCG9yZGVyX2lkGAcgASgJUgdvcmRlcklkEhYKBnN0YXR1cxgIIAEoCVIGc3RhdHVzEh'
'YKBnJlYXNvbhgJIAEoCVIGcmVhc29uEhQKBWFjdG9yGAogASgJUgVhY3RvchIlCg5jb3JyZWxh'
'dGlvbl9pZBgLIAEoCVINY29ycmVsYXRpb25JZBo6CgxQYXlsb2FkRW50cnkSEAoDa2V5GAEgAS'
'gJUgNrZXkSFAoFdmFsdWUYAiABKAlSBXZhbHVlOgI4AQ==');
@$core.Deprecated('Use listLiveAuditEventsRequestDescriptor instead')
const ListLiveAuditEventsRequest$json = {
'1': 'ListLiveAuditEventsRequest',
'2': [
{'1': 'account_id', '3': 1, '4': 1, '5': 9, '10': 'accountId'},
{'1': 'order_id', '3': 2, '4': 1, '5': 9, '10': 'orderId'},
{'1': 'event_type', '3': 3, '4': 1, '5': 9, '10': 'eventType'},
{'1': 'limit', '3': 4, '4': 1, '5': 5, '10': 'limit'},
],
};
/// Descriptor for `ListLiveAuditEventsRequest`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List listLiveAuditEventsRequestDescriptor =
$convert.base64Decode(
'ChpMaXN0TGl2ZUF1ZGl0RXZlbnRzUmVxdWVzdBIdCgphY2NvdW50X2lkGAEgASgJUglhY2NvdW'
'50SWQSGQoIb3JkZXJfaWQYAiABKAlSB29yZGVySWQSHQoKZXZlbnRfdHlwZRgDIAEoCVIJZXZl'
'bnRUeXBlEhQKBWxpbWl0GAQgASgFUgVsaW1pdA==');
@$core.Deprecated('Use listLiveAuditEventsResponseDescriptor instead')
const ListLiveAuditEventsResponse$json = {
'1': 'ListLiveAuditEventsResponse',
'2': [
{
'1': 'events',
'3': 1,
'4': 3,
'5': 11,
'6': '.alt.v1.LiveAuditEvent',
'10': 'events'
},
{
'1': 'error',
'3': 2,
'4': 1,
'5': 11,
'6': '.alt.v1.ErrorInfo',
'10': 'error'
},
],
};
/// Descriptor for `ListLiveAuditEventsResponse`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List listLiveAuditEventsResponseDescriptor =
$convert.base64Decode(
'ChtMaXN0TGl2ZUF1ZGl0RXZlbnRzUmVzcG9uc2USLgoGZXZlbnRzGAEgAygLMhYuYWx0LnYxLk'
'xpdmVBdWRpdEV2ZW50UgZldmVudHMSJwoFZXJyb3IYAiABKAsyES5hbHQudjEuRXJyb3JJbmZv'
'UgVlcnJvcg==');
@$core.Deprecated('Use operatorConfirmationDescriptor instead')
const OperatorConfirmation$json = {

View file

@ -56,9 +56,16 @@ void main() {
SetLiveKillSwitchResponse(),
LiveKillSwitchState(),
LiveRiskDecision(),
SyncLiveAccountRequest(),
SyncLiveAccountResponse(),
GetLiveAccountSnapshotRequest(),
GetLiveAccountSnapshotResponse(),
LiveAccountSnapshot(),
LiveCashBalance(),
LivePositionSnapshot(),
];
expect(parsers.length, equals(42));
expect(parsers.length, equals(49));
for (final msg in expectedMessages) {
final qualifiedName = msg.info_.qualifiedMessageName;

File diff suppressed because it is too large Load diff

View file

@ -84,14 +84,44 @@ message LiveOrder {
int64 updated_at_unix_ms = 16;
}
// LiveAccountSnapshot carries live account state from a broker.
// LiveCashBalance is a single currency-denominated cash entry in a live account.
// Raw account numbers and provider-specific identifiers are never included.
message LiveCashBalance {
Currency currency = 1;
Decimal amount = 2;
}
// LiveAccountSnapshot carries sanitized live account state from a broker.
// Raw account numbers and provider payload are never exposed.
message LiveAccountSnapshot {
string broker = 1;
string account_id = 2;
Decimal cash = 3;
Decimal total_equity = 4;
Decimal available_margin = 5;
int64 snapshot_time_unix_ms = 6;
string account_id = 1;
string broker = 2;
repeated LiveCashBalance cash = 3;
repeated LivePositionSnapshot positions = 4;
int64 synced_at_unix_ms = 5;
bool stale = 6;
}
// SyncLiveAccountRequest triggers a fresh account snapshot fetch from the broker.
message SyncLiveAccountRequest {
string account_id = 1;
}
// SyncLiveAccountResponse returns the refreshed snapshot or a typed error.
message SyncLiveAccountResponse {
LiveAccountSnapshot snapshot = 1;
ErrorInfo error = 2;
}
// GetLiveAccountSnapshotRequest retrieves the last synced account snapshot.
message GetLiveAccountSnapshotRequest {
string account_id = 1;
}
// GetLiveAccountSnapshotResponse returns the cached snapshot or a typed error.
message GetLiveAccountSnapshotResponse {
LiveAccountSnapshot snapshot = 1;
ErrorInfo error = 2;
}
// LivePositionSnapshot is a single position inside a live account.
@ -105,13 +135,36 @@ message LivePositionSnapshot {
Decimal pnl = 7;
}
// LiveAuditEvent is a broker-neutral event record for audit trails.
// LiveAuditEvent is a durable, broker-neutral record of a live operation event.
// Payload must never carry raw tokens, account numbers, or provider bodies.
message LiveAuditEvent {
string type = 1;
int64 timestamp_unix_ms = 2;
string broker = 3;
string account_id = 4;
map<string, string> detail = 5;
map<string, string> payload = 5;
string event_id = 6;
string order_id = 7;
string status = 8;
string reason = 9;
string actor = 10;
string correlation_id = 11;
}
// ListLiveAuditEventsRequest queries the durable audit trail with optional
// filters by account, order, and event type.
message ListLiveAuditEventsRequest {
string account_id = 1;
string order_id = 2;
string event_type = 3;
int32 limit = 4;
}
// ListLiveAuditEventsResponse returns the matching audit event records or a
// typed error.
message ListLiveAuditEventsResponse {
repeated LiveAuditEvent events = 1;
ErrorInfo error = 2;
}
// OperatorConfirmation is the explicit operator gate required before any live

View file

@ -0,0 +1,27 @@
package trading
import "sort"
// SortCashBalances sorts cash balances by currency string for stable output.
// It modifies the slice in-place so snapshot serialisation is deterministic.
func SortCashBalances(balances []CashBalance) {
sort.Slice(balances, func(i, j int) bool {
return string(balances[i].Currency) < string(balances[j].Currency)
})
}
// SortPositionSnapshots sorts position snapshots by instrument ID for stable
// output. It modifies the slice in-place.
func SortPositionSnapshots(positions []PositionSnapshot) {
sort.Slice(positions, func(i, j int) bool {
return string(positions[i].InstrumentID) < string(positions[j].InstrumentID)
})
}
// HasRawAccountNumber returns false unconditionally; it documents the invariant
// that AccountSnapshot must never hold a raw broker account number. The raw
// account identifier is resolved inside the adapter and must not escape the
// BrokerPort boundary.
func HasRawAccountNumber(_ AccountSnapshot) bool {
return false
}

View file

@ -0,0 +1,97 @@
package trading
import (
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
func sampleAccountSnapshot() AccountSnapshot {
return AccountSnapshot{
Broker: BrokerKIS,
AccountID: "op-alias-001",
Cash: []CashBalance{
{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "1000"}},
{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5000000"}},
},
Positions: []PositionSnapshot{
{
InstrumentID: "KRX:005930",
Side: OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}},
},
},
SyncedAt: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC),
Stale: false,
}
}
func TestSortCashBalancesIsStable(t *testing.T) {
snap := sampleAccountSnapshot()
// USD comes before KRW alphabetically; after sort KRW should be first.
SortCashBalances(snap.Cash)
if len(snap.Cash) != 2 {
t.Fatalf("expected 2 cash balances, got %d", len(snap.Cash))
}
if snap.Cash[0].Currency != market.CurrencyKRW {
t.Errorf("expected KRW first after sort, got %q", snap.Cash[0].Currency)
}
if snap.Cash[1].Currency != market.CurrencyUSD {
t.Errorf("expected USD second after sort, got %q", snap.Cash[1].Currency)
}
}
func TestSortCashBalancesEmptyIsNoop(t *testing.T) {
SortCashBalances(nil)
SortCashBalances([]CashBalance{})
}
func TestSortPositionSnapshotsIsStable(t *testing.T) {
positions := []PositionSnapshot{
{InstrumentID: "US:MSFT"},
{InstrumentID: "KRX:005930"},
}
SortPositionSnapshots(positions)
if positions[0].InstrumentID != "KRX:005930" {
t.Errorf("expected KRX:005930 first, got %q", positions[0].InstrumentID)
}
if positions[1].InstrumentID != "US:MSFT" {
t.Errorf("expected US:MSFT second, got %q", positions[1].InstrumentID)
}
}
func TestAccountSnapshotHasNoRawAccountNumber(t *testing.T) {
snap := sampleAccountSnapshot()
if HasRawAccountNumber(snap) {
t.Error("AccountSnapshot must not carry a raw account number")
}
}
func TestAccountSnapshotMultiCurrencyCashPreserved(t *testing.T) {
snap := sampleAccountSnapshot()
if len(snap.Cash) != 2 {
t.Errorf("expected 2 cash entries, got %d", len(snap.Cash))
}
foundKRW := false
foundUSD := false
for _, c := range snap.Cash {
if c.Currency == market.CurrencyKRW {
foundKRW = true
}
if c.Currency == market.CurrencyUSD {
foundUSD = true
}
}
if !foundKRW || !foundUSD {
t.Error("expected KRW and USD cash balances to be preserved")
}
}
func TestStaleSnapshotFlagIsPreserved(t *testing.T) {
snap := sampleAccountSnapshot()
snap.Stale = true
if !snap.Stale {
t.Error("expected Stale=true to be preserved")
}
}

View file

@ -0,0 +1,46 @@
package trading
// AuditEventType identifies the kind of live operation audit event. The
// vocabulary is additive: new types can be introduced without changing the
// type definition or requiring a migration.
type AuditEventType string
const (
AuditEventTypeSubmitConfirmed AuditEventType = "submit_confirmed"
AuditEventTypeBrokerAccepted AuditEventType = "broker_accepted"
AuditEventTypeBrokerRejected AuditEventType = "broker_rejected"
AuditEventTypeCancelConfirmed AuditEventType = "cancel_confirmed"
AuditEventTypeRiskBlocked AuditEventType = "risk_blocked"
AuditEventTypeKillSwitchSet AuditEventType = "kill_switch_set"
AuditEventTypeAccountSyncSuccess AuditEventType = "account_sync_success"
AuditEventTypeAccountSyncFailure AuditEventType = "account_sync_failure"
)
// AuditFilter selects a subset of live audit events for a list query. A
// zero-value AuditFilter matches all events with no further restriction.
type AuditFilter struct {
AccountID string
OrderID string
EventType AuditEventType
Limit int
}
// blockedPayloadKeys is the set of map keys that must never appear in an audit
// event payload. Raw secrets, tokens, and provider-specific identifiers are
// forbidden at the domain boundary.
var blockedPayloadKeys = []string{
"token", "access_token", "secret", "password",
"raw_account_number", "api_key", "authorization", "raw_body",
}
// HasRawSecretKey returns true if payload contains a key from the blocked
// list. Callers that build audit payloads must reject payloads that fail this
// check before appending to storage.
func HasRawSecretKey(payload map[string]string) bool {
for _, blocked := range blockedPayloadKeys {
if _, ok := payload[blocked]; ok {
return true
}
}
return false
}

View file

@ -0,0 +1,99 @@
package trading_test
import (
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/trading"
)
func TestAuditEventTypeStability(t *testing.T) {
// AuditEventType strings are stored in the database; they must remain stable
// across versions.
cases := []struct {
typ trading.AuditEventType
want string
}{
{trading.AuditEventTypeSubmitConfirmed, "submit_confirmed"},
{trading.AuditEventTypeBrokerAccepted, "broker_accepted"},
{trading.AuditEventTypeBrokerRejected, "broker_rejected"},
{trading.AuditEventTypeCancelConfirmed, "cancel_confirmed"},
{trading.AuditEventTypeRiskBlocked, "risk_blocked"},
{trading.AuditEventTypeKillSwitchSet, "kill_switch_set"},
{trading.AuditEventTypeAccountSyncSuccess, "account_sync_success"},
{trading.AuditEventTypeAccountSyncFailure, "account_sync_failure"},
}
for _, c := range cases {
if string(c.typ) != c.want {
t.Errorf("AuditEventType changed: got %q, want %q", string(c.typ), c.want)
}
}
}
func TestHasRawSecretKeyBlocksForbiddenKeys(t *testing.T) {
blockedPayloads := []map[string]string{
{"token": "abc123"},
{"access_token": "xyz"},
{"secret": "shh"},
{"password": "hunter2"},
{"raw_account_number": "123456"},
{"api_key": "key"},
{"authorization": "Bearer tok"},
{"raw_body": `{"raw":true}`},
}
for _, p := range blockedPayloads {
if !trading.HasRawSecretKey(p) {
t.Errorf("expected HasRawSecretKey=true for payload %v", p)
}
}
}
func TestHasRawSecretKeyAllowsSafePayload(t *testing.T) {
safe := map[string]string{
"order_id": "lo-acct-1",
"status": "submitted",
"instrument_id": "005930.KS",
}
if trading.HasRawSecretKey(safe) {
t.Error("expected HasRawSecretKey=false for safe payload")
}
if trading.HasRawSecretKey(nil) {
t.Error("expected HasRawSecretKey=false for nil payload")
}
}
func TestAuditFilterDefaults(t *testing.T) {
var f trading.AuditFilter
if f.AccountID != "" || f.OrderID != "" || f.EventType != "" || f.Limit != 0 {
t.Error("zero-value AuditFilter must have empty/zero fields")
}
}
func TestAuditEventFields(t *testing.T) {
now := time.Now().UTC()
ev := trading.AuditEvent{
EventID: "evt-1",
Type: trading.AuditEventTypeSubmitConfirmed,
Timestamp: now,
Broker: trading.BrokerKIS,
AccountID: "acct-1",
OrderID: "lo-acct-1-1",
Status: "submitted",
Reason: "operator confirmed",
Actor: "op-001",
CorrelationID: "corr-001",
Payload: map[string]string{"instrument_id": "005930.KS"},
}
if ev.EventID == "" {
t.Error("expected non-empty EventID")
}
if ev.Type != trading.AuditEventTypeSubmitConfirmed {
t.Errorf("expected AuditEventTypeSubmitConfirmed, got %q", ev.Type)
}
if ev.Timestamp != now {
t.Error("Timestamp does not match")
}
if trading.HasRawSecretKey(ev.Payload) {
t.Error("safe payload must not be flagged as containing raw secret key")
}
}

View file

@ -93,14 +93,22 @@ func (o OrderIntent) OrderType() OrderType {
return o.Type
}
// AccountSnapshot carries live account state reported by a broker.
// CashBalance is a single currency-denominated cash entry in a live account.
// Raw account numbers and provider identifiers are never included.
type CashBalance struct {
Currency market.Currency
Amount market.Decimal
}
// AccountSnapshot carries sanitized live account state reported by a broker.
// Raw account numbers and provider-specific payload are never stored here.
type AccountSnapshot struct {
Broker Broker
AccountID string
Cash market.Decimal
TotalEquity market.Decimal
Available_margin market.Decimal
SnapshotTime time.Time
Broker Broker
AccountID string
Cash []CashBalance
Positions []PositionSnapshot
SyncedAt time.Time
Stale bool
}
// PositionSnapshot is a single position inside a live account.
@ -114,14 +122,21 @@ type PositionSnapshot struct {
PnL market.Decimal
}
// AuditEvent is a broker-neutral event record that can be replayed or
// inspected later. The Type field is additive.
// AuditEvent is a durable, broker-neutral record of a live operation event.
// Payload must never contain raw tokens, account numbers, or provider bodies;
// call HasRawSecretKey before storing.
type AuditEvent struct {
Type string
Timestamp time.Time
Broker Broker
AccountID string
Detail map[string]string
EventID string
Type AuditEventType
Timestamp time.Time
Broker Broker
AccountID string
OrderID string
Status string
Reason string
Actor string
CorrelationID string
Payload map[string]string
}
// BrokerCapability describes what a live broker supports. The method set is

View file

@ -67,24 +67,22 @@ func TestLiveOrderIntentKeepsCustomBrokerOrderType(t *testing.T) {
}
func TestAccountSnapshotRequiresNoFloatMath(t *testing.T) {
// Verify that AccountSnapshot uses market.Decimal for all monetary
// fields and contains no float fields. This is a compile-time +
// structural check: if a field were float64, the struct layout would
// differ, but the real guarantee is in the type itself.
// Verify that AccountSnapshot uses CashBalance slices for monetary fields
// so broker multi-currency accounts are always represented without floats.
snap := AccountSnapshot{
Broker: BrokerKIS,
AccountID: "test",
Cash: market.Decimal{},
TotalEquity: market.Decimal{},
Available_margin: market.Decimal{},
SnapshotTime: time.Now(),
Broker: BrokerKIS,
AccountID: "test",
Cash: []CashBalance{
{Currency: market.CurrencyKRW, Amount: market.Decimal{}},
},
}
// Compile-time assurance: these fields must be market.Decimal.
_ = snap.Cash
_ = snap.TotalEquity
_ = snap.Available_margin
if len(snap.Cash) != 1 {
t.Fatalf("expected 1 cash entry, got %d", len(snap.Cash))
}
// CashBalance.Amount must be market.Decimal (no float).
_ = snap.Cash[0].Amount
// Similarly, PositionSnapshot.PnL uses market.Decimal.
// PositionSnapshot.PnL uses market.Decimal.
pos := PositionSnapshot{
InstrumentID: "005930.KS",
Side: OrderSideBuy,

View file

@ -72,6 +72,18 @@ func messageFactories() []func() proto.Message {
func() proto.Message { return &altv1.LiveKillSwitchState{} },
// live risk decision: included in SubmitLiveOrderResponse when risk blocked
func() proto.Message { return &altv1.LiveRiskDecision{} },
// live account sync surface: sync from broker / get last snapshot
func() proto.Message { return &altv1.SyncLiveAccountRequest{} },
func() proto.Message { return &altv1.SyncLiveAccountResponse{} },
func() proto.Message { return &altv1.GetLiveAccountSnapshotRequest{} },
func() proto.Message { return &altv1.GetLiveAccountSnapshotResponse{} },
func() proto.Message { return &altv1.LiveAccountSnapshot{} },
func() proto.Message { return &altv1.LiveCashBalance{} },
func() proto.Message { return &altv1.LivePositionSnapshot{} },
// live audit trail surface: list audit events
func() proto.Message { return &altv1.ListLiveAuditEventsRequest{} },
func() proto.Message { return &altv1.ListLiveAuditEventsResponse{} },
func() proto.Message { return &altv1.LiveAuditEvent{} },
}
}

View file

@ -45,6 +45,14 @@ func requiredAPIMessages() []proto.Message {
&altv1.GetPaperTradingStateRequest{},
&altv1.GetPaperTradingStateResponse{},
&altv1.PaperTradingState{},
// live account sync surface: sync from broker / get last snapshot
&altv1.SyncLiveAccountRequest{},
&altv1.SyncLiveAccountResponse{},
&altv1.GetLiveAccountSnapshotRequest{},
&altv1.GetLiveAccountSnapshotResponse{},
&altv1.LiveAccountSnapshot{},
&altv1.LiveCashBalance{},
&altv1.LivePositionSnapshot{},
}
}

View file

@ -36,6 +36,9 @@ type fakeWorkerClient struct {
liveRiskPolicyReq *altv1.GetLiveRiskPolicyRequest
liveGetKillSwitchReq *altv1.GetLiveKillSwitchRequest
liveSetKillSwitchReq *altv1.SetLiveKillSwitchRequest
liveSyncAccReq *altv1.SyncLiveAccountRequest
liveGetAccSnapReq *altv1.GetLiveAccountSnapshotRequest
liveListAuditReq *altv1.ListLiveAuditEventsRequest
startRes *altv1.StartBacktestResponse
getRunRes *altv1.GetBacktestRunResponse
@ -58,6 +61,9 @@ type fakeWorkerClient struct {
liveRiskPolicyRes *altv1.GetLiveRiskPolicyResponse
liveGetKillSwitchRes *altv1.GetLiveKillSwitchResponse
liveSetKillSwitchRes *altv1.SetLiveKillSwitchResponse
liveSyncAccRes *altv1.SyncLiveAccountResponse
liveGetAccSnapRes *altv1.GetLiveAccountSnapshotResponse
liveListAuditRes *altv1.ListLiveAuditEventsResponse
err error
connectErr error
@ -171,6 +177,18 @@ func (f *fakeWorkerClient) SetLiveKillSwitch(ctx context.Context, req *altv1.Set
f.liveSetKillSwitchReq = req
return f.liveSetKillSwitchRes, f.err
}
func (f *fakeWorkerClient) SyncLiveAccount(ctx context.Context, req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
f.liveSyncAccReq = req
return f.liveSyncAccRes, f.err
}
func (f *fakeWorkerClient) GetLiveAccountSnapshot(ctx context.Context, req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
f.liveGetAccSnapReq = req
return f.liveGetAccSnapRes, f.err
}
func (f *fakeWorkerClient) ListLiveAuditEvents(ctx context.Context, req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
f.liveListAuditReq = req
return f.liveListAuditRes, f.err
}
func validStart() *altv1.StartBacktestRequest {
return &altv1.StartBacktestRequest{Spec: &altv1.BacktestRunSpec{StrategyId: "strat-abc"}}

View file

@ -70,9 +70,57 @@ func apiLiveHandlers(worker workerclient.WorkerClient) []sessionHandler {
})
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.SyncLiveAccountRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.SyncLiveAccountRequest, *altv1.SyncLiveAccountResponse](&client.Communicator, func(req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
return handleSyncLiveAccount(worker, req)
})
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveAccountSnapshotRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetLiveAccountSnapshotRequest, *altv1.GetLiveAccountSnapshotResponse](&client.Communicator, func(req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
return handleGetLiveAccountSnapshot(worker, req)
})
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.ListLiveAuditEventsRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.ListLiveAuditEventsRequest, *altv1.ListLiveAuditEventsResponse](&client.Communicator, func(req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
return handleListLiveAuditEvents(worker, req)
})
},
},
}
}
func handleListLiveAuditEvents(worker workerclient.WorkerClient, req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
if req.GetAccountId() == "" {
return &altv1.ListLiveAuditEventsResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "list_live_audit_events: account_id is required"},
}, nil
}
if worker == nil {
return &altv1.ListLiveAuditEventsResponse{Error: workerUnavailable()}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
defer cancel()
if err := worker.Connect(ctx); err != nil {
return &altv1.ListLiveAuditEventsResponse{Error: workerErrorInfo(err)}, nil
}
res, err := worker.ListLiveAuditEvents(ctx, req)
if err != nil {
return &altv1.ListLiveAuditEventsResponse{Error: workerErrorInfo(err)}, nil
}
if res == nil {
return &altv1.ListLiveAuditEventsResponse{Error: internalError("worker returned no list live audit events response")}, nil
}
return res, nil
}
func handleGetLiveRiskPolicy(worker workerclient.WorkerClient, req *altv1.GetLiveRiskPolicyRequest) (*altv1.GetLiveRiskPolicyResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveRiskPolicyResponse{
@ -227,6 +275,54 @@ func handleCancelLiveOrder(worker workerclient.WorkerClient, req *altv1.CancelLi
return res, nil
}
func handleSyncLiveAccount(worker workerclient.WorkerClient, req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
if req.GetAccountId() == "" {
return &altv1.SyncLiveAccountResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "sync_live_account: account_id is required"},
}, nil
}
if worker == nil {
return &altv1.SyncLiveAccountResponse{Error: workerUnavailable()}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
defer cancel()
if err := worker.Connect(ctx); err != nil {
return &altv1.SyncLiveAccountResponse{Error: workerErrorInfo(err)}, nil
}
res, err := worker.SyncLiveAccount(ctx, req)
if err != nil {
return &altv1.SyncLiveAccountResponse{Error: workerErrorInfo(err)}, nil
}
if res == nil {
return &altv1.SyncLiveAccountResponse{Error: internalError("worker returned no sync live account response")}, nil
}
return res, nil
}
func handleGetLiveAccountSnapshot(worker workerclient.WorkerClient, req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveAccountSnapshotResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "get_live_account_snapshot: account_id is required"},
}, nil
}
if worker == nil {
return &altv1.GetLiveAccountSnapshotResponse{Error: workerUnavailable()}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), workerRequestTimeout)
defer cancel()
if err := worker.Connect(ctx); err != nil {
return &altv1.GetLiveAccountSnapshotResponse{Error: workerErrorInfo(err)}, nil
}
res, err := worker.GetLiveAccountSnapshot(ctx, req)
if err != nil {
return &altv1.GetLiveAccountSnapshotResponse{Error: workerErrorInfo(err)}, nil
}
if res == nil {
return &altv1.GetLiveAccountSnapshotResponse{Error: internalError("worker returned no get live account snapshot response")}, nil
}
return res, nil
}
func handleGetLiveOrder(worker workerclient.WorkerClient, req *altv1.GetLiveOrderRequest) (*altv1.GetLiveOrderResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveOrderResponse{

View file

@ -387,3 +387,190 @@ func TestHandleGetLiveOrderForward(t *testing.T) {
t.Errorf("expected broker_status FILLED, got %q", resp.GetOrder().GetBrokerStatus())
}
}
func TestHandleSyncLiveAccountForward(t *testing.T) {
snap := &altv1.LiveAccountSnapshot{AccountId: "op-alias-001", Broker: "kis"}
worker := &fakeWorkerClient{
liveSyncAccRes: &altv1.SyncLiveAccountResponse{Snapshot: snap},
isConnected: true,
}
resp, err := handleSyncLiveAccount(worker, &altv1.SyncLiveAccountRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if worker.liveSyncAccReq == nil {
t.Fatal("expected request to be forwarded to worker")
}
if resp.GetSnapshot().GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", resp.GetSnapshot().GetAccountId())
}
}
func TestHandleSyncLiveAccountEmptyAccountID(t *testing.T) {
worker := &fakeWorkerClient{isConnected: true}
resp, err := handleSyncLiveAccount(worker, &altv1.SyncLiveAccountRequest{AccountId: ""})
if err != nil {
t.Fatalf("expected typed response, got error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected error, got nil")
}
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
}
if worker.liveSyncAccReq != nil {
t.Error("worker should not be called when account_id is empty")
}
}
func TestHandleSyncLiveAccountNilWorker(t *testing.T) {
resp, err := handleSyncLiveAccount(nil, &altv1.SyncLiveAccountRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("expected typed response, got error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected error, got nil")
}
if resp.GetError().GetCode() != backtestErrorUnavailable {
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
}
}
func TestHandleGetLiveAccountSnapshotForward(t *testing.T) {
snap := &altv1.LiveAccountSnapshot{AccountId: "op-alias-001", Broker: "kis", Stale: false}
worker := &fakeWorkerClient{
liveGetAccSnapRes: &altv1.GetLiveAccountSnapshotResponse{Snapshot: snap},
isConnected: true,
}
resp, err := handleGetLiveAccountSnapshot(worker, &altv1.GetLiveAccountSnapshotRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if worker.liveGetAccSnapReq == nil {
t.Fatal("expected request to be forwarded to worker")
}
if resp.GetSnapshot().GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", resp.GetSnapshot().GetAccountId())
}
}
func TestHandleGetLiveAccountSnapshotEmptyAccountID(t *testing.T) {
worker := &fakeWorkerClient{isConnected: true}
resp, err := handleGetLiveAccountSnapshot(worker, &altv1.GetLiveAccountSnapshotRequest{AccountId: ""})
if err != nil {
t.Fatalf("expected typed response, got error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected error, got nil")
}
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
}
}
func TestHandleGetLiveAccountSnapshotNilWorker(t *testing.T) {
resp, err := handleGetLiveAccountSnapshot(nil, &altv1.GetLiveAccountSnapshotRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("expected typed response, got error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected error, got nil")
}
if resp.GetError().GetCode() != backtestErrorUnavailable {
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
}
}
func TestApiLiveHandlersRegistrationIncludesAccountSync(t *testing.T) {
required := map[string]bool{
"alt.v1.SyncLiveAccountRequest": false,
"alt.v1.GetLiveAccountSnapshotRequest": false,
}
for _, h := range apiLiveHandlers(nil) {
if _, ok := required[h.requestType]; ok {
required[h.requestType] = true
}
}
for name, seen := range required {
if !seen {
t.Errorf("missing API live handler registration: %s", name)
}
}
}
func TestApiLiveHandlersRegistrationIncludesAuditQuery(t *testing.T) {
found := false
for _, h := range apiLiveHandlers(nil) {
if h.requestType == "alt.v1.ListLiveAuditEventsRequest" {
found = true
break
}
}
if !found {
t.Error("missing API live handler registration: alt.v1.ListLiveAuditEventsRequest")
}
}
func TestHandleListLiveAuditEventsForward(t *testing.T) {
auditEvents := []*altv1.LiveAuditEvent{
{EventId: "aud-1", Type: "submit_confirmed", AccountId: "acct-1", OrderId: "lo-1"},
}
worker := &fakeWorkerClient{
liveListAuditRes: &altv1.ListLiveAuditEventsResponse{Events: auditEvents},
isConnected: true,
}
resp, err := handleListLiveAuditEvents(worker, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if worker.liveListAuditReq == nil {
t.Fatal("expected request to be forwarded to worker")
}
if len(resp.GetEvents()) != 1 {
t.Fatalf("expected 1 event, got %d", len(resp.GetEvents()))
}
if resp.GetEvents()[0].GetEventId() != "aud-1" {
t.Errorf("event_id mismatch: %q", resp.GetEvents()[0].GetEventId())
}
}
func TestHandleListLiveAuditEventsEmptyAccountID(t *testing.T) {
worker := &fakeWorkerClient{isConnected: true}
resp, err := handleListLiveAuditEvents(worker, &altv1.ListLiveAuditEventsRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
}
if worker.liveListAuditReq != nil {
t.Error("worker should not be called when account_id is empty")
}
}
func TestHandleListLiveAuditEventsNilWorker(t *testing.T) {
resp, err := handleListLiveAuditEvents(nil, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorUnavailable {
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
}
}

View file

@ -72,6 +72,15 @@ type WorkerClient interface {
GetLiveRiskPolicy(ctx context.Context, req *altv1.GetLiveRiskPolicyRequest) (*altv1.GetLiveRiskPolicyResponse, error)
GetLiveKillSwitch(ctx context.Context, req *altv1.GetLiveKillSwitchRequest) (*altv1.GetLiveKillSwitchResponse, error)
SetLiveKillSwitch(ctx context.Context, req *altv1.SetLiveKillSwitchRequest) (*altv1.SetLiveKillSwitchResponse, error)
// Live account sync surface. Fetch/get stay worker-owned; the API only
// validates request shape and forwards.
SyncLiveAccount(ctx context.Context, req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error)
GetLiveAccountSnapshot(ctx context.Context, req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error)
// Live audit trail query surface. The audit store lives in the worker;
// the API only validates and forwards.
ListLiveAuditEvents(ctx context.Context, req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error)
}
type socketClient struct {
@ -240,6 +249,18 @@ func (c *socketClient) SetLiveKillSwitch(ctx context.Context, req *altv1.SetLive
return sendTyped[*altv1.SetLiveKillSwitchRequest, *altv1.SetLiveKillSwitchResponse](c, ctx, req)
}
func (c *socketClient) SyncLiveAccount(ctx context.Context, req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
return sendTyped[*altv1.SyncLiveAccountRequest, *altv1.SyncLiveAccountResponse](c, ctx, req)
}
func (c *socketClient) GetLiveAccountSnapshot(ctx context.Context, req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
return sendTyped[*altv1.GetLiveAccountSnapshotRequest, *altv1.GetLiveAccountSnapshotResponse](c, ctx, req)
}
func (c *socketClient) ListLiveAuditEvents(ctx context.Context, req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
return sendTyped[*altv1.ListLiveAuditEventsRequest, *altv1.ListLiveAuditEventsResponse](c, ctx, req)
}
// sendTyped is the shared request path for every worker call. It centralises
// context-cancellation, deadline-derived timeouts, and the unavailable/timeout
// error mapping so each new request method stays a one-line forwarder and the

View file

@ -15,6 +15,7 @@ import (
workerbacktest "git.toki-labs.com/toki/alt/services/worker/internal/backtest"
"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/livetrading"
"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"
@ -56,6 +57,7 @@ func main() {
"backtest_start_surface", deps.Starter != nil,
"market_read_surface", deps.Instruments != nil && deps.Bars != nil,
"market_import_surface", deps.DailyBarImporter != nil,
"live_audit_surface", deps.Live != nil,
"addr", fmt.Sprintf("%s:%d%s", cfg.Host, cfg.Port, cfg.SocketPath),
)
@ -93,6 +95,12 @@ func storeBackedDeps(runner *jobs.Runner, store *postgres.Store) socket.Deps {
paperEngine := papertrading.NewEngine(barSource, strategyPort)
paperService := papertrading.NewService(paperEngine, time.Now)
// Live trading service: nil broker means live order operations are unavailable
// until a broker adapter is wired. The audit store is always injected so the
// audit query surface is available as soon as the database pool is up.
liveSvc := livetrading.NewService(nil)
liveSvc.SetAuditStore(store)
return socket.Deps{
Starter: starter,
Analysis: store,
@ -101,5 +109,6 @@ func storeBackedDeps(runner *jobs.Runner, store *postgres.Store) socket.Deps {
Bars: store,
DailyBarImporter: kisImporter,
Paper: paperService,
Live: liveSvc,
}
}

View file

@ -31,6 +31,9 @@ func TestStoreBackedDepsWiresBacktestSurfaces(t *testing.T) {
if deps.Paper == nil {
t.Error("expected paper trading service to be wired")
}
if deps.Live == nil {
t.Error("expected live trading service to be wired (audit query surface must be up even without a broker)")
}
// Wiring registers the import and run-backtest job handlers on the runner, so
// it must hold at least those two handlers after wiring.

View file

@ -68,6 +68,18 @@ func messageFactories() []func() proto.Message {
func() proto.Message { return &altv1.LiveKillSwitchState{} },
// live risk decision: included in SubmitLiveOrderResponse when risk blocked
func() proto.Message { return &altv1.LiveRiskDecision{} },
// live account sync surface: sync from broker / get last snapshot
func() proto.Message { return &altv1.SyncLiveAccountRequest{} },
func() proto.Message { return &altv1.SyncLiveAccountResponse{} },
func() proto.Message { return &altv1.GetLiveAccountSnapshotRequest{} },
func() proto.Message { return &altv1.GetLiveAccountSnapshotResponse{} },
func() proto.Message { return &altv1.LiveAccountSnapshot{} },
func() proto.Message { return &altv1.LiveCashBalance{} },
func() proto.Message { return &altv1.LivePositionSnapshot{} },
// live audit trail surface: list audit events
func() proto.Message { return &altv1.ListLiveAuditEventsRequest{} },
func() proto.Message { return &altv1.ListLiveAuditEventsResponse{} },
func() proto.Message { return &altv1.LiveAuditEvent{} },
}
}

View file

@ -41,6 +41,14 @@ func requiredWorkerMessages() []proto.Message {
&altv1.GetPaperTradingStateRequest{},
&altv1.GetPaperTradingStateResponse{},
&altv1.PaperTradingState{},
// live account sync surface: sync from broker / get last snapshot
&altv1.SyncLiveAccountRequest{},
&altv1.SyncLiveAccountResponse{},
&altv1.GetLiveAccountSnapshotRequest{},
&altv1.GetLiveAccountSnapshotResponse{},
&altv1.LiveAccountSnapshot{},
&altv1.LiveCashBalance{},
&altv1.LivePositionSnapshot{},
}
}

View file

@ -19,6 +19,10 @@ type BrokerPort interface {
SubmitOrder(ctx context.Context, accountID string, intent trading.OrderIntent) (OrderResult, error)
CancelOrder(ctx context.Context, accountID, brokerOrderID string) (OrderResult, error)
GetOrderStatus(ctx context.Context, accountID, brokerOrderID string) (OrderResult, error)
// FetchAccountSnapshot retrieves a sanitized account snapshot from the broker.
// Raw account numbers and provider payload must be stripped inside the adapter
// before returning; the snapshot AccountID must be an operator alias or internal id.
FetchAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error)
}
// OrderResult carries the broker-reported outcome of an order operation.
@ -76,8 +80,21 @@ var (
// ErrRiskBlocked is returned when the kill switch is halted or the order
// intent violates the active risk policy. The broker is never called.
ErrRiskBlocked = errors.New("live order blocked by risk policy")
// ErrAccountSnapshotNotFound is returned when GetLiveAccountSnapshot is
// called for an account that has not yet been synced.
ErrAccountSnapshotNotFound = errors.New("live account snapshot not found")
// ErrAuditFailed is returned when an audit event cannot be persisted. The
// operation itself may have succeeded, but the audit trail is mandatory.
ErrAuditFailed = errors.New("live audit event failed to persist")
)
// AuditStore is the narrow interface the Service uses to persist and query live
// operation audit events. storage.LiveAuditStore satisfies this interface.
type AuditStore interface {
AppendLiveAuditEvent(ctx context.Context, event trading.AuditEvent) error
ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error)
}
// Service is the worker-owned live trading runtime. It keeps an in-memory order
// registry and delegates execution to a BrokerPort. A nil BrokerPort means live
// trading is disabled; all order operations return ErrBrokerUnavailable.
@ -92,6 +109,11 @@ type Service struct {
acctMuMu sync.Mutex // protects acctMus
acctMus map[string]*sync.Mutex // per-account submit serialization
snapshotMu sync.RWMutex
snapshots map[string]*trading.AccountSnapshot // keyed by accountID
auditStore AuditStore // optional; nil means no-op audit trail
}
// NewService creates a Service backed by broker with the default risk policy
@ -104,9 +126,47 @@ func NewService(broker BrokerPort) *Service {
policy: DefaultRiskPolicy(),
killSwitch: trading.DefaultKillSwitch(),
acctMus: make(map[string]*sync.Mutex),
snapshots: make(map[string]*trading.AccountSnapshot),
}
}
// SetAuditStore injects an optional audit store for durable audit trail. When
// nil (the default), audit events are silently discarded.
func (s *Service) SetAuditStore(store AuditStore) {
s.auditStore = store
}
// newAuditEventID returns a process-unique audit event identifier.
func newAuditEventID() string {
return fmt.Sprintf("aud-%d", time.Now().UnixNano())
}
// appendAudit persists ev to the audit store when one is configured. Returns
// ErrAuditFailed (wrapping the store error) on failure so callers can propagate
// it as a typed internal error. Forbidden payload keys (raw secrets, tokens,
// account numbers) are rejected at this boundary before reaching the store.
func (s *Service) appendAudit(ctx context.Context, ev trading.AuditEvent) error {
if s.auditStore == nil {
return nil
}
if trading.HasRawSecretKey(ev.Payload) {
return fmt.Errorf("%w: audit payload contains forbidden key", ErrAuditFailed)
}
if err := s.auditStore.AppendLiveAuditEvent(ctx, ev); err != nil {
return fmt.Errorf("%w: %v", ErrAuditFailed, err)
}
return nil
}
// ListLiveAuditEvents delegates to the configured AuditStore. Returns nil when
// no audit store is configured.
func (s *Service) ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error) {
if s.auditStore == nil {
return nil, nil
}
return s.auditStore.ListLiveAuditEvents(ctx, filter)
}
// submitLockForAccount returns the per-account mutex for submit serialization,
// creating it if it does not yet exist.
func (s *Service) submitLockForAccount(accountID string) *sync.Mutex {
@ -142,10 +202,22 @@ func (s *Service) GetKillSwitch() trading.KillSwitchState {
}
// SetKillSwitch replaces the active kill switch state and returns the new state.
// The kill_switch_set audit event is emitted best-effort (failure is silently
// discarded) because the kill switch must succeed even when audit storage is
// unavailable.
func (s *Service) SetKillSwitch(ks trading.KillSwitchState) trading.KillSwitchState {
s.policyMu.Lock()
s.killSwitch = ks
s.policyMu.Unlock()
now := time.Now().UTC()
_ = s.appendAudit(context.Background(), trading.AuditEvent{
EventID: newAuditEventID(),
Type: trading.AuditEventTypeKillSwitchSet,
Timestamp: now,
Status: fmt.Sprintf("halted=%v", ks.Halted),
Reason: ks.Reason,
Payload: map[string]string{"halted": fmt.Sprintf("%v", ks.Halted)},
})
return ks
}
@ -233,7 +305,38 @@ func (s *Service) SubmitLiveOrder(ctx context.Context, req SubmitRequest) (LiveO
s.mu.RUnlock()
if decision := trading.EvaluateRisk(policy, ks, req.Intent, usage); !decision.Allowed {
return s.recordRiskRejectedOrder(req, decision, now)
order, riskErr := s.recordRiskRejectedOrder(ctx, req, decision, now)
if auditErr := s.appendAudit(ctx, trading.AuditEvent{
EventID: newAuditEventID(),
Type: trading.AuditEventTypeRiskBlocked,
Timestamp: now,
AccountID: req.AccountID,
OrderID: order.ID,
Status: string(trading.OrderStatusRejected),
Reason: decision.Reason,
Actor: req.Confirmation.OperatorID,
Payload: map[string]string{"instrument_id": string(req.Intent.InstrumentID), "reason": decision.Reason},
}); auditErr != nil {
return order, auditErr
}
return order, riskErr
}
// Emit submit_confirmed before calling the broker so the operator gate
// decision is recorded even if the broker call later fails.
if auditErr := s.appendAudit(ctx, trading.AuditEvent{
EventID: newAuditEventID(),
Type: trading.AuditEventTypeSubmitConfirmed,
Timestamp: now,
AccountID: req.AccountID,
Actor: req.Confirmation.OperatorID,
Payload: map[string]string{
"instrument_id": string(req.Intent.InstrumentID),
"side": string(req.Intent.Side),
"type": string(req.Intent.Type),
},
}); auditErr != nil {
return LiveOrder{}, auditErr
}
result, err := s.broker.SubmitOrder(ctx, req.AccountID, req.Intent)
@ -258,6 +361,26 @@ func (s *Service) SubmitLiveOrder(ctx context.Context, req SubmitRequest) (LiveO
s.orders[id] = order
s.mu.Unlock()
// Emit broker_accepted or broker_rejected to record the broker's decision.
brokerEvType := trading.AuditEventTypeBrokerAccepted
if order.Status == trading.OrderStatusRejected {
brokerEvType = trading.AuditEventTypeBrokerRejected
}
if auditErr := s.appendAudit(ctx, trading.AuditEvent{
EventID: newAuditEventID(),
Type: brokerEvType,
Timestamp: order.CreatedAt,
AccountID: order.AccountID,
OrderID: order.ID,
Status: string(order.Status),
Reason: order.RejectionReason,
Actor: req.Confirmation.OperatorID,
Payload: map[string]string{
"broker_order_id": order.BrokerOrderID,
},
}); auditErr != nil {
return *order, auditErr
}
return *order, nil
}
@ -293,7 +416,7 @@ func (s *Service) riskUsageForAccount(accountID string, now time.Time) trading.R
// recordRiskRejectedOrder creates an in-memory rejected live order for a
// risk-blocked submission, stores it, and returns both the order and the
// typed ErrRiskBlocked error so the socket layer can surface both.
func (s *Service) recordRiskRejectedOrder(req SubmitRequest, decision trading.RiskDecision, now time.Time) (LiveOrder, error) {
func (s *Service) recordRiskRejectedOrder(ctx context.Context, req SubmitRequest, decision trading.RiskDecision, now time.Time) (LiveOrder, error) {
s.mu.Lock()
s.seq++
id := fmt.Sprintf("lo-%s-%d", req.AccountID, s.seq)
@ -335,11 +458,74 @@ func (s *Service) CancelLiveOrder(ctx context.Context, accountID, liveOrderID st
order.Status = result.Status
order.BrokerStatus = result.BrokerStatus
order.UpdatedAt = time.Now().UTC()
updatedAt := order.UpdatedAt
s.mu.Unlock()
if auditErr := s.appendAudit(ctx, trading.AuditEvent{
EventID: newAuditEventID(),
Type: trading.AuditEventTypeCancelConfirmed,
Timestamp: updatedAt,
AccountID: accountID,
OrderID: liveOrderID,
Status: string(order.Status),
Payload: map[string]string{"broker_order_id": order.BrokerOrderID},
}); auditErr != nil {
return *order, auditErr
}
return *order, nil
}
// SyncLiveAccount fetches a fresh account snapshot from the broker, stores it
// in memory, and returns it. A nil broker returns ErrBrokerUnavailable.
// The snapshot is sanitized by the BrokerPort adapter; no raw account numbers
// or provider payload reach this layer.
func (s *Service) SyncLiveAccount(ctx context.Context, accountID string) (trading.AccountSnapshot, error) {
if s.broker == nil {
return trading.AccountSnapshot{}, ErrBrokerUnavailable
}
snap, err := s.broker.FetchAccountSnapshot(ctx, accountID)
if err != nil {
// Emit failure audit best-effort; don't mask the original broker error.
_ = s.appendAudit(ctx, trading.AuditEvent{
EventID: newAuditEventID(),
Type: trading.AuditEventTypeAccountSyncFailure,
Timestamp: time.Now().UTC(),
AccountID: accountID,
Status: "failed",
Reason: err.Error(),
})
return trading.AccountSnapshot{}, err
}
s.snapshotMu.Lock()
s.snapshots[accountID] = &snap
s.snapshotMu.Unlock()
if auditErr := s.appendAudit(ctx, trading.AuditEvent{
EventID: newAuditEventID(),
Type: trading.AuditEventTypeAccountSyncSuccess,
Timestamp: snap.SyncedAt,
Broker: snap.Broker,
AccountID: accountID,
Status: "synced",
Payload: map[string]string{"cash_count": fmt.Sprintf("%d", len(snap.Cash)), "position_count": fmt.Sprintf("%d", len(snap.Positions))},
}); auditErr != nil {
return trading.AccountSnapshot{}, auditErr
}
return snap, nil
}
// GetLiveAccountSnapshot returns the last synced account snapshot for
// accountID. Returns ErrAccountSnapshotNotFound when no sync has been
// performed yet for that account.
func (s *Service) GetLiveAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error) {
s.snapshotMu.RLock()
snap, ok := s.snapshots[accountID]
s.snapshotMu.RUnlock()
if !ok {
return trading.AccountSnapshot{}, ErrAccountSnapshotNotFound
}
return *snap, nil
}
// GetLiveOrder fetches the current broker status for a live order and refreshes
// the in-memory record with the broker response before returning it.
func (s *Service) GetLiveOrder(ctx context.Context, accountID, liveOrderID string) (LiveOrder, error) {

View file

@ -3,6 +3,7 @@ package livetrading
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
@ -22,6 +23,8 @@ type fakeBroker struct {
statusErr error
caps trading.BrokerCapability
capsErr error
snapshot trading.AccountSnapshot
snapshotErr error
submitCalls int
lastAccountID string
lastIntent trading.OrderIntent
@ -46,6 +49,10 @@ func (f *fakeBroker) GetOrderStatus(_ context.Context, _, _ string) (OrderResult
return f.statusResult, f.statusErr
}
func (f *fakeBroker) FetchAccountSnapshot(_ context.Context, _ string) (trading.AccountSnapshot, error) {
return f.snapshot, f.snapshotErr
}
func confirmedReq(accountID, instrumentID, orderType string) SubmitRequest {
return SubmitRequest{
AccountID: accountID,
@ -588,6 +595,10 @@ func (b *blockingFakeBroker) GetOrderStatus(_ context.Context, _, _ string) (Ord
return OrderResult{}, nil
}
func (b *blockingFakeBroker) FetchAccountSnapshot(_ context.Context, _ string) (trading.AccountSnapshot, error) {
return trading.AccountSnapshot{}, nil
}
func (b *blockingFakeBroker) calls() int {
b.mu.Lock()
defer b.mu.Unlock()
@ -767,3 +778,417 @@ func TestSubmitLiveOrderBrokerUnavailableWithDefaultKillSwitch(t *testing.T) {
t.Error("ErrBrokerUnavailable must take precedence over ErrRiskBlocked for nil broker")
}
}
func sampleAccountSnapshot(accountID string) trading.AccountSnapshot {
return trading.AccountSnapshot{
Broker: trading.BrokerKIS,
AccountID: accountID,
Cash: []trading.CashBalance{
{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5000000"}},
},
Positions: []trading.PositionSnapshot{
{
InstrumentID: "KRX:005930",
Side: trading.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}},
},
},
SyncedAt: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC),
Stale: false,
}
}
func TestSyncLiveAccountSuccess(t *testing.T) {
snap := sampleAccountSnapshot("op-alias-001")
broker := &fakeBroker{snapshot: snap}
svc := NewService(broker)
got, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.AccountID != "op-alias-001" {
t.Errorf("account_id mismatch: %q", got.AccountID)
}
if got.Broker != trading.BrokerKIS {
t.Errorf("broker mismatch: %q", got.Broker)
}
if len(got.Cash) != 1 {
t.Fatalf("expected 1 cash entry, got %d", len(got.Cash))
}
if got.Stale {
t.Error("expected Stale=false")
}
// Raw account number must not appear in AccountID
if got.AccountID == "1234567890" {
t.Error("raw account number must not be exposed in AccountID")
}
}
func TestSyncLiveAccountNilBrokerReturnsUnavailable(t *testing.T) {
svc := NewService(nil)
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
if !errors.Is(err, ErrBrokerUnavailable) {
t.Fatalf("expected ErrBrokerUnavailable, got %v", err)
}
}
func TestSyncLiveAccountBrokerErrorPropagates(t *testing.T) {
broker := &fakeBroker{snapshotErr: errors.New("connection refused")}
svc := NewService(broker)
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
if err == nil {
t.Fatal("expected error from broker, got nil")
}
}
func TestGetLiveAccountSnapshotNotFoundBeforeSync(t *testing.T) {
svc := NewService(&fakeBroker{})
_, err := svc.GetLiveAccountSnapshot(context.Background(), "op-alias-001")
if !errors.Is(err, ErrAccountSnapshotNotFound) {
t.Fatalf("expected ErrAccountSnapshotNotFound before sync, got %v", err)
}
}
// fakeAuditStore records all appended audit events and can be configured to
// return an error on the next AppendLiveAuditEvent call.
type fakeAuditStore struct {
events []trading.AuditEvent
appendErr error
listErr error
}
func (f *fakeAuditStore) AppendLiveAuditEvent(_ context.Context, ev trading.AuditEvent) error {
if f.appendErr != nil {
return f.appendErr
}
f.events = append(f.events, ev)
return nil
}
func (f *fakeAuditStore) ListLiveAuditEvents(_ context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error) {
if f.listErr != nil {
return nil, f.listErr
}
var out []trading.AuditEvent
for _, ev := range f.events {
if filter.AccountID != "" && ev.AccountID != filter.AccountID {
continue
}
if filter.OrderID != "" && ev.OrderID != filter.OrderID {
continue
}
if filter.EventType != "" && ev.Type != filter.EventType {
continue
}
out = append(out, ev)
if filter.Limit > 0 && len(out) >= filter.Limit {
break
}
}
return out, nil
}
func auditStoreEvents(store *fakeAuditStore) []trading.AuditEventType {
types := make([]trading.AuditEventType, len(store.events))
for i, ev := range store.events {
types[i] = ev.Type
}
return types
}
func TestSubmitLiveOrderEmitsSubmitConfirmedAudit(t *testing.T) {
broker := &fakeBroker{
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
}
audit := &fakeAuditStore{}
svc := NewService(broker)
svc.SetAuditStore(audit)
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
req := confirmedReq("acct-audit", "KRX:005930", "market")
req.Confirmation.OperatorID = "op-1"
order, err := svc.SubmitLiveOrder(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// A successful submit emits submit_confirmed (operator gate) then broker_accepted.
evsByType := make(map[trading.AuditEventType][]trading.AuditEvent)
for _, ev := range audit.events {
evsByType[ev.Type] = append(evsByType[ev.Type], ev)
}
submitEvents := evsByType[trading.AuditEventTypeSubmitConfirmed]
if len(submitEvents) != 1 {
t.Fatalf("expected 1 submit_confirmed event, got %d (all types: %v)", len(submitEvents), auditStoreEvents(audit))
}
sc := submitEvents[0]
if sc.AccountID != "acct-audit" {
t.Errorf("submit_confirmed account_id mismatch: %q", sc.AccountID)
}
if sc.Actor != "op-1" {
t.Errorf("submit_confirmed actor mismatch: %q", sc.Actor)
}
if sc.EventID == "" {
t.Error("submit_confirmed event_id must be non-empty")
}
// submit_confirmed is emitted before broker call, so OrderID is not yet set.
if sc.OrderID != "" {
t.Errorf("submit_confirmed should not carry order_id (emitted before broker), got %q", sc.OrderID)
}
acceptedEvents := evsByType[trading.AuditEventTypeBrokerAccepted]
if len(acceptedEvents) != 1 {
t.Fatalf("expected 1 broker_accepted event, got %d", len(acceptedEvents))
}
ba := acceptedEvents[0]
if ba.OrderID != order.ID {
t.Errorf("broker_accepted order_id mismatch: got %q, want %q", ba.OrderID, order.ID)
}
if ba.AccountID != "acct-audit" {
t.Errorf("broker_accepted account_id mismatch: %q", ba.AccountID)
}
}
func TestSubmitLiveOrderBrokerRejectedEmitsBothAuditEvents(t *testing.T) {
broker := &fakeBroker{
submitResult: OrderResult{BrokerOrderID: "", Status: trading.OrderStatusRejected, BrokerStatus: "REJECTED", RejectionReason: "insufficient balance"},
}
audit := &fakeAuditStore{}
svc := NewService(broker)
svc.SetAuditStore(audit)
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
req := confirmedReq("acct-audit", "KRX:005930", "market")
req.Confirmation.OperatorID = "op-1"
order, err := svc.SubmitLiveOrder(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if order.Status != trading.OrderStatusRejected {
t.Fatalf("expected rejected order, got %q", order.Status)
}
evsByType := make(map[trading.AuditEventType][]trading.AuditEvent)
for _, ev := range audit.events {
evsByType[ev.Type] = append(evsByType[ev.Type], ev)
}
if len(evsByType[trading.AuditEventTypeSubmitConfirmed]) != 1 {
t.Fatalf("expected 1 submit_confirmed, got %d", len(evsByType[trading.AuditEventTypeSubmitConfirmed]))
}
rejectedEvents := evsByType[trading.AuditEventTypeBrokerRejected]
if len(rejectedEvents) != 1 {
t.Fatalf("expected 1 broker_rejected event, got %d", len(rejectedEvents))
}
br := rejectedEvents[0]
if br.OrderID != order.ID {
t.Errorf("broker_rejected order_id mismatch: got %q, want %q", br.OrderID, order.ID)
}
if br.Reason != "insufficient balance" {
t.Errorf("broker_rejected reason mismatch: %q", br.Reason)
}
}
func TestSubmitLiveOrderRiskBlockedEmitsRiskBlockedAudit(t *testing.T) {
broker := &fakeBroker{
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
}
audit := &fakeAuditStore{}
svc := NewService(broker)
svc.SetAuditStore(audit)
// Kill switch halted by default → ErrRiskBlocked
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-audit", "KRX:005930", "market"))
if !errors.Is(err, ErrRiskBlocked) {
t.Fatalf("expected ErrRiskBlocked, got %v", err)
}
var riskEvents []trading.AuditEvent
for _, ev := range audit.events {
if ev.Type == trading.AuditEventTypeRiskBlocked {
riskEvents = append(riskEvents, ev)
}
}
if len(riskEvents) != 1 {
t.Fatalf("expected 1 risk_blocked event, got %d", len(riskEvents))
}
if riskEvents[0].AccountID != "acct-audit" {
t.Errorf("account_id mismatch: %q", riskEvents[0].AccountID)
}
}
func TestSubmitLiveOrderAuditFailureReturnsErrAuditFailed(t *testing.T) {
broker := &fakeBroker{
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
}
audit := &fakeAuditStore{appendErr: errors.New("db down")}
svc := NewService(broker)
svc.SetAuditStore(audit)
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
_, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-audit", "KRX:005930", "market"))
if !errors.Is(err, ErrAuditFailed) {
t.Fatalf("expected ErrAuditFailed when audit store errors, got %v", err)
}
}
func TestCancelLiveOrderEmitsCancelConfirmedAudit(t *testing.T) {
broker := &fakeBroker{
submitResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusSubmitted, BrokerStatus: "ACCEPTED"},
cancelResult: OrderResult{BrokerOrderID: "b-1", Status: trading.OrderStatusCanceled, BrokerStatus: "CANCELED"},
}
audit := &fakeAuditStore{}
svc := NewService(broker)
svc.SetAuditStore(audit)
svc.SetKillSwitch(trading.KillSwitchState{Halted: false})
order, err := svc.SubmitLiveOrder(context.Background(), confirmedReq("acct-audit", "KRX:005930", "limit"))
if err != nil {
t.Fatalf("submit error: %v", err)
}
_, err = svc.CancelLiveOrder(context.Background(), "acct-audit", order.ID)
if err != nil {
t.Fatalf("cancel error: %v", err)
}
var cancelEvents []trading.AuditEvent
for _, ev := range audit.events {
if ev.Type == trading.AuditEventTypeCancelConfirmed {
cancelEvents = append(cancelEvents, ev)
}
}
if len(cancelEvents) != 1 {
t.Fatalf("expected 1 cancel_confirmed event, got %d", len(cancelEvents))
}
if cancelEvents[0].OrderID != order.ID {
t.Errorf("order_id mismatch: got %q, want %q", cancelEvents[0].OrderID, order.ID)
}
}
func TestSyncLiveAccountEmitsAccountSyncSuccessAudit(t *testing.T) {
snap := sampleAccountSnapshot("op-alias-001")
broker := &fakeBroker{snapshot: snap}
audit := &fakeAuditStore{}
svc := NewService(broker)
svc.SetAuditStore(audit)
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var syncEvents []trading.AuditEvent
for _, ev := range audit.events {
if ev.Type == trading.AuditEventTypeAccountSyncSuccess {
syncEvents = append(syncEvents, ev)
}
}
if len(syncEvents) != 1 {
t.Fatalf("expected 1 account_sync_success event, got %d", len(syncEvents))
}
if syncEvents[0].AccountID != "op-alias-001" {
t.Errorf("account_id mismatch: %q", syncEvents[0].AccountID)
}
if syncEvents[0].Broker != trading.BrokerKIS {
t.Errorf("broker mismatch: %q", syncEvents[0].Broker)
}
}
func TestSyncLiveAccountBrokerErrorEmitsAccountSyncFailureAudit(t *testing.T) {
broker := &fakeBroker{snapshotErr: errors.New("network timeout")}
audit := &fakeAuditStore{}
svc := NewService(broker)
svc.SetAuditStore(audit)
_, err := svc.SyncLiveAccount(context.Background(), "op-alias-001")
if err == nil {
t.Fatal("expected error from broker")
}
var failEvents []trading.AuditEvent
for _, ev := range audit.events {
if ev.Type == trading.AuditEventTypeAccountSyncFailure {
failEvents = append(failEvents, ev)
}
}
if len(failEvents) != 1 {
t.Fatalf("expected 1 account_sync_failure event, got %d", len(failEvents))
}
}
func TestAppendAuditRejectsForbiddenPayloadKey(t *testing.T) {
audit := &fakeAuditStore{}
svc := NewService(nil)
svc.SetAuditStore(audit)
ev := trading.AuditEvent{
EventID: "aud-bad",
Type: trading.AuditEventTypeSubmitConfirmed,
AccountID: "acct-1",
Payload: map[string]string{"token": "raw-secret"},
}
err := svc.appendAudit(context.Background(), ev)
if !errors.Is(err, ErrAuditFailed) {
t.Fatalf("expected ErrAuditFailed for forbidden payload key, got %v", err)
}
if len(audit.events) != 0 {
t.Error("store must not be called when payload contains forbidden key")
}
}
func TestListLiveAuditEventsNoStore(t *testing.T) {
svc := NewService(nil)
events, err := svc.ListLiveAuditEvents(context.Background(), trading.AuditFilter{AccountID: "acct-1"})
if err != nil {
t.Fatalf("expected nil error when no store, got %v", err)
}
if events != nil {
t.Errorf("expected nil events when no store, got %v", events)
}
}
func TestListLiveAuditEventsDelegatesFilter(t *testing.T) {
audit := &fakeAuditStore{}
svc := NewService(nil)
svc.SetAuditStore(audit)
for i := 0; i < 3; i++ {
_ = audit.AppendLiveAuditEvent(context.Background(), trading.AuditEvent{
EventID: fmt.Sprintf("aud-%d", i),
Type: trading.AuditEventTypeSubmitConfirmed,
AccountID: "acct-1",
OrderID: fmt.Sprintf("lo-acct-1-%d", i),
})
}
events, err := svc.ListLiveAuditEvents(context.Background(), trading.AuditFilter{AccountID: "acct-1", Limit: 2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(events) != 2 {
t.Errorf("expected 2 events with limit=2, got %d", len(events))
}
}
func TestGetLiveAccountSnapshotAfterSync(t *testing.T) {
snap := sampleAccountSnapshot("op-alias-001")
broker := &fakeBroker{snapshot: snap}
svc := NewService(broker)
if _, err := svc.SyncLiveAccount(context.Background(), "op-alias-001"); err != nil {
t.Fatalf("sync failed: %v", err)
}
got, err := svc.GetLiveAccountSnapshot(context.Background(), "op-alias-001")
if err != nil {
t.Fatalf("get failed: %v", err)
}
if got.AccountID != "op-alias-001" {
t.Errorf("account_id mismatch: %q", got.AccountID)
}
if len(got.Positions) != 1 {
t.Errorf("expected 1 position, got %d", len(got.Positions))
}
}

View file

@ -11,9 +11,9 @@ import (
)
// LiveService is the narrow interface for live broker capability discovery,
// the live order lifecycle, and the risk/kill switch control surface.
// The livetrading package provides the concrete process-local implementation;
// the narrow interface keeps the handlers testable with a fake.
// the live order lifecycle, the risk/kill switch control surface, and account
// snapshot sync. The livetrading package provides the concrete process-local
// implementation; the narrow interface keeps the handlers testable with a fake.
type LiveService interface {
GetLiveBrokerCapabilities(ctx context.Context, accountID string) (trading.BrokerCapability, error)
SubmitLiveOrder(ctx context.Context, req livetrading.SubmitRequest) (livetrading.LiveOrder, error)
@ -22,10 +22,35 @@ type LiveService interface {
GetRiskPolicy() trading.RiskPolicy
GetKillSwitch() trading.KillSwitchState
SetKillSwitch(ks trading.KillSwitchState) trading.KillSwitchState
SyncLiveAccount(ctx context.Context, accountID string) (trading.AccountSnapshot, error)
GetLiveAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error)
ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error)
}
func liveHandlers(deps Deps) []sessionHandler {
return []sessionHandler{
{
requestType: protoSocket.TypeNameOf(&altv1.SyncLiveAccountRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.SyncLiveAccountRequest, *altv1.SyncLiveAccountResponse](
&client.Communicator,
func(req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
return handleSyncLiveAccount(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveAccountSnapshotRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.GetLiveAccountSnapshotRequest, *altv1.GetLiveAccountSnapshotResponse](
&client.Communicator,
func(req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
return handleGetLiveAccountSnapshot(deps, req)
},
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.GetLiveBrokerCapabilitiesRequest{}),
register: func(client *protoSocket.WsClient) {
@ -103,6 +128,17 @@ func liveHandlers(deps Deps) []sessionHandler {
)
},
},
{
requestType: protoSocket.TypeNameOf(&altv1.ListLiveAuditEventsRequest{}),
register: func(client *protoSocket.WsClient) {
protoSocket.AddRequestListenerTyped[*altv1.ListLiveAuditEventsRequest, *altv1.ListLiveAuditEventsResponse](
&client.Communicator,
func(req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
return handleListLiveAuditEvents(deps, req)
},
)
},
},
}
}
@ -305,3 +341,88 @@ func liveOrderErrorCode(err error) (code, message string) {
return backtestErrorInternal, err.Error()
}
}
func handleSyncLiveAccount(deps Deps, req *altv1.SyncLiveAccountRequest) (*altv1.SyncLiveAccountResponse, error) {
if req.GetAccountId() == "" {
return &altv1.SyncLiveAccountResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "sync_live_account: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.SyncLiveAccountResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
snap, err := deps.Live.SyncLiveAccount(ctx, req.GetAccountId())
if err != nil {
code, msg := liveAccountErrorCode(err)
return &altv1.SyncLiveAccountResponse{
Error: &altv1.ErrorInfo{Code: code, Message: msg},
}, nil
}
return &altv1.SyncLiveAccountResponse{Snapshot: accountSnapshotToProto(snap)}, nil
}
func handleGetLiveAccountSnapshot(deps Deps, req *altv1.GetLiveAccountSnapshotRequest) (*altv1.GetLiveAccountSnapshotResponse, error) {
if req.GetAccountId() == "" {
return &altv1.GetLiveAccountSnapshotResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "get_live_account_snapshot: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.GetLiveAccountSnapshotResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
snap, err := deps.Live.GetLiveAccountSnapshot(ctx, req.GetAccountId())
if err != nil {
code, msg := liveAccountErrorCode(err)
return &altv1.GetLiveAccountSnapshotResponse{
Error: &altv1.ErrorInfo{Code: code, Message: msg},
}, nil
}
return &altv1.GetLiveAccountSnapshotResponse{Snapshot: accountSnapshotToProto(snap)}, nil
}
func handleListLiveAuditEvents(deps Deps, req *altv1.ListLiveAuditEventsRequest) (*altv1.ListLiveAuditEventsResponse, error) {
if req.GetAccountId() == "" {
return &altv1.ListLiveAuditEventsResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInvalidRequest, Message: "list_live_audit_events: account_id is required"},
}, nil
}
if deps.Live == nil {
return &altv1.ListLiveAuditEventsResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorUnavailable, Message: "live trading is not available"},
}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), handlerTimeout)
defer cancel()
filter := protoToAuditFilter(req)
events, err := deps.Live.ListLiveAuditEvents(ctx, filter)
if err != nil {
return &altv1.ListLiveAuditEventsResponse{
Error: &altv1.ErrorInfo{Code: backtestErrorInternal, Message: err.Error()},
}, nil
}
protoEvents := make([]*altv1.LiveAuditEvent, 0, len(events))
for _, ev := range events {
protoEvents = append(protoEvents, auditEventToProto(ev))
}
return &altv1.ListLiveAuditEventsResponse{Events: protoEvents}, nil
}
func liveAccountErrorCode(err error) (code, message string) {
switch {
case isLiveErr(err, livetrading.ErrBrokerUnavailable):
return backtestErrorUnavailable, err.Error()
case isLiveErr(err, livetrading.ErrAccountSnapshotNotFound):
return backtestErrorNotFound, err.Error()
default:
return backtestErrorInternal, err.Error()
}
}

View file

@ -10,6 +10,44 @@ import (
"git.toki-labs.com/toki/alt/services/worker/internal/livetrading"
)
func accountSnapshotToProto(snap trading.AccountSnapshot) *altv1.LiveAccountSnapshot {
proto := &altv1.LiveAccountSnapshot{
AccountId: snap.AccountID,
Broker: string(snap.Broker),
SyncedAtUnixMs: snap.SyncedAt.UnixMilli(),
Stale: snap.Stale,
}
for _, cb := range snap.Cash {
proto.Cash = append(proto.Cash, &altv1.LiveCashBalance{
Currency: currencyToProto(cb.Currency),
Amount: &altv1.Decimal{Value: cb.Amount.Value},
})
}
for _, pos := range snap.Positions {
proto.Positions = append(proto.Positions, positionSnapshotToProto(pos))
}
return proto
}
func positionSnapshotToProto(pos trading.PositionSnapshot) *altv1.LivePositionSnapshot {
p := &altv1.LivePositionSnapshot{
InstrumentId: string(pos.InstrumentID),
Side: string(pos.Side),
MarketValue: &altv1.Decimal{Value: pos.MarketValue.Value},
Pnl: &altv1.Decimal{Value: pos.PnL.Value},
}
if pos.Quantity.Amount.Value != "" {
p.Quantity = &altv1.Quantity{Amount: &altv1.Decimal{Value: pos.Quantity.Amount.Value}}
}
if pos.AvgPrice.Amount.Value != "" {
p.AvgPrice = priceToProto(pos.AvgPrice)
}
if pos.CurrentPrice.Amount.Value != "" {
p.CurrentPrice = priceToProto(pos.CurrentPrice)
}
return p
}
func liveCapabilityToProto(c trading.BrokerCapability) *altv1.LiveBrokerCapability {
cap := &altv1.LiveBrokerCapability{
Broker: string(c.Broker),
@ -144,3 +182,28 @@ func riskDecisionToProto(d trading.RiskDecision) *altv1.LiveRiskDecision {
Reason: d.Reason,
}
}
func protoToAuditFilter(req *altv1.ListLiveAuditEventsRequest) trading.AuditFilter {
return trading.AuditFilter{
AccountID: req.GetAccountId(),
OrderID: req.GetOrderId(),
EventType: trading.AuditEventType(req.GetEventType()),
Limit: int(req.GetLimit()),
}
}
func auditEventToProto(ev trading.AuditEvent) *altv1.LiveAuditEvent {
return &altv1.LiveAuditEvent{
EventId: ev.EventID,
Type: string(ev.Type),
TimestampUnixMs: ev.Timestamp.UnixMilli(),
Broker: string(ev.Broker),
AccountId: ev.AccountID,
Payload: ev.Payload,
OrderId: ev.OrderID,
Status: ev.Status,
Reason: ev.Reason,
Actor: ev.Actor,
CorrelationId: ev.CorrelationID,
}
}

View file

@ -15,16 +15,22 @@ import (
)
type fakeLiveService struct {
cap trading.BrokerCapability
capErr error
submitOrder livetrading.LiveOrder
submitErr error
cancelOrder livetrading.LiveOrder
cancelErr error
getOrder livetrading.LiveOrder
getErr error
policy trading.RiskPolicy
killSwitch trading.KillSwitchState
cap trading.BrokerCapability
capErr error
submitOrder livetrading.LiveOrder
submitErr error
cancelOrder livetrading.LiveOrder
cancelErr error
getOrder livetrading.LiveOrder
getErr error
policy trading.RiskPolicy
killSwitch trading.KillSwitchState
syncSnap trading.AccountSnapshot
syncSnapErr error
getSnap trading.AccountSnapshot
getSnapErr error
listAuditEvents []trading.AuditEvent
listAuditErr error
}
func (f *fakeLiveService) GetLiveBrokerCapabilities(ctx context.Context, accountID string) (trading.BrokerCapability, error) {
@ -555,3 +561,262 @@ func TestSubmitLiveOrderSocketRoundTrip(t *testing.T) {
t.Errorf("unexpected order id: %q", resp.GetOrder().GetId())
}
}
func (f *fakeLiveService) SyncLiveAccount(ctx context.Context, accountID string) (trading.AccountSnapshot, error) {
return f.syncSnap, f.syncSnapErr
}
func (f *fakeLiveService) GetLiveAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error) {
return f.getSnap, f.getSnapErr
}
func (f *fakeLiveService) ListLiveAuditEvents(_ context.Context, _ trading.AuditFilter) ([]trading.AuditEvent, error) {
return f.listAuditEvents, f.listAuditErr
}
func sampleAccountSnapshotForSocket() trading.AccountSnapshot {
return trading.AccountSnapshot{
Broker: trading.BrokerKIS,
AccountID: "op-alias-001",
Cash: []trading.CashBalance{
{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "5000000"}},
},
Positions: []trading.PositionSnapshot{
{
InstrumentID: "KRX:005930",
Side: trading.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}},
},
},
Stale: false,
}
}
func TestHandleSyncLiveAccountSuccess(t *testing.T) {
snap := sampleAccountSnapshotForSocket()
svc := &fakeLiveService{syncSnap: snap}
deps := Deps{Live: svc}
resp, err := handleSyncLiveAccount(deps, &altv1.SyncLiveAccountRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
s := resp.GetSnapshot()
if s == nil {
t.Fatal("expected snapshot in response")
}
if s.GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", s.GetAccountId())
}
if s.GetBroker() != "kis" {
t.Errorf("broker mismatch: %q", s.GetBroker())
}
if len(s.GetCash()) != 1 {
t.Errorf("expected 1 cash entry, got %d", len(s.GetCash()))
}
if s.GetStale() {
t.Error("expected stale=false")
}
}
func TestHandleSyncLiveAccountEmptyAccountIDReturnsInvalidRequest(t *testing.T) {
svc := &fakeLiveService{}
deps := Deps{Live: svc}
resp, err := handleSyncLiveAccount(deps, &altv1.SyncLiveAccountRequest{AccountId: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
}
}
func TestHandleSyncLiveAccountNilServiceReturnsUnavailable(t *testing.T) {
resp, err := handleSyncLiveAccount(Deps{}, &altv1.SyncLiveAccountRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorUnavailable {
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
}
}
func TestHandleGetLiveAccountSnapshotNotFound(t *testing.T) {
svc := &fakeLiveService{getSnapErr: livetrading.ErrAccountSnapshotNotFound}
deps := Deps{Live: svc}
resp, err := handleGetLiveAccountSnapshot(deps, &altv1.GetLiveAccountSnapshotRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorNotFound {
t.Errorf("expected not_found, got %q", resp.GetError().GetCode())
}
}
func TestHandleGetLiveAccountSnapshotSuccess(t *testing.T) {
snap := sampleAccountSnapshotForSocket()
svc := &fakeLiveService{getSnap: snap}
deps := Deps{Live: svc}
resp, err := handleGetLiveAccountSnapshot(deps, &altv1.GetLiveAccountSnapshotRequest{AccountId: "op-alias-001"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if resp.GetSnapshot() == nil {
t.Fatal("expected snapshot in response")
}
if resp.GetSnapshot().GetAccountId() != "op-alias-001" {
t.Errorf("account_id mismatch: %q", resp.GetSnapshot().GetAccountId())
}
}
func TestLiveHandlersRegistrationIncludesAccountSync(t *testing.T) {
required := map[string]bool{
"alt.v1.SyncLiveAccountRequest": false,
"alt.v1.GetLiveAccountSnapshotRequest": false,
}
for _, h := range liveHandlers(Deps{}) {
if _, ok := required[h.requestType]; ok {
required[h.requestType] = true
}
}
for name, seen := range required {
if !seen {
t.Errorf("missing live handler registration: %s", name)
}
}
}
func TestLiveHandlersRegistrationIncludesAuditQuery(t *testing.T) {
found := false
for _, h := range liveHandlers(Deps{}) {
if h.requestType == "alt.v1.ListLiveAuditEventsRequest" {
found = true
break
}
}
if !found {
t.Error("missing live handler registration: alt.v1.ListLiveAuditEventsRequest")
}
}
func TestHandleListLiveAuditEventsSuccess(t *testing.T) {
events := []trading.AuditEvent{
{
EventID: "aud-1",
Type: trading.AuditEventTypeSubmitConfirmed,
Timestamp: time.Now().UTC(),
AccountID: "acct-1",
OrderID: "lo-acct-1-1",
Status: "submitted",
Actor: "op-001",
},
{
EventID: "aud-2",
Type: trading.AuditEventTypeRiskBlocked,
Timestamp: time.Now().UTC(),
AccountID: "acct-1",
Status: "blocked",
},
}
svc := &fakeLiveService{listAuditEvents: events}
deps := Deps{Live: svc}
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if len(resp.GetEvents()) != 2 {
t.Fatalf("expected 2 events, got %d", len(resp.GetEvents()))
}
if resp.GetEvents()[0].GetEventId() != "aud-1" {
t.Errorf("event_id mismatch: %q", resp.GetEvents()[0].GetEventId())
}
if resp.GetEvents()[0].GetType() != string(trading.AuditEventTypeSubmitConfirmed) {
t.Errorf("type mismatch: %q", resp.GetEvents()[0].GetType())
}
if resp.GetEvents()[0].GetActor() != "op-001" {
t.Errorf("actor mismatch: %q", resp.GetEvents()[0].GetActor())
}
}
func TestHandleListLiveAuditEventsEmptyAccountIDReturnsInvalidRequest(t *testing.T) {
svc := &fakeLiveService{}
deps := Deps{Live: svc}
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorInvalidRequest {
t.Errorf("expected invalid_request, got %q", resp.GetError().GetCode())
}
}
func TestHandleListLiveAuditEventsNilServiceReturnsUnavailable(t *testing.T) {
resp, err := handleListLiveAuditEvents(Deps{}, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorUnavailable {
t.Errorf("expected unavailable, got %q", resp.GetError().GetCode())
}
}
func TestHandleListLiveAuditEventsServiceErrorReturnsInternal(t *testing.T) {
svc := &fakeLiveService{listAuditErr: errors.New("store unavailable")}
deps := Deps{Live: svc}
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() == nil {
t.Fatal("expected typed error, got nil")
}
if resp.GetError().GetCode() != backtestErrorInternal {
t.Errorf("expected internal, got %q", resp.GetError().GetCode())
}
}
func TestHandleListLiveAuditEventsEmptyResultIsOK(t *testing.T) {
svc := &fakeLiveService{listAuditEvents: nil}
deps := Deps{Live: svc}
resp, err := handleListLiveAuditEvents(deps, &altv1.ListLiveAuditEventsRequest{AccountId: "acct-1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetError() != nil {
t.Fatalf("unexpected typed error: %+v", resp.GetError())
}
if len(resp.GetEvents()) != 0 {
t.Errorf("expected empty events, got %d", len(resp.GetEvents()))
}
}

View file

@ -7,6 +7,7 @@ import (
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/packages/domain/trading"
)
var ErrRunNotFound = errors.New("backtest run not found")
@ -50,3 +51,11 @@ type BacktestAnalysisStore interface {
GetRunDetail(ctx context.Context, id backtest.RunID) (RunDetail, error)
CompareResults(ctx context.Context, ids []backtest.RunID) ([]backtest.Result, error)
}
// LiveAuditStore persists and retrieves durable live operation audit events.
// AppendLiveAuditEvent must return a typed internal error on failure so callers
// can surface audit failures without silently discarding them.
type LiveAuditStore interface {
AppendLiveAuditEvent(ctx context.Context, event trading.AuditEvent) error
ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error)
}

View file

@ -9,6 +9,7 @@ import (
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/packages/domain/trading"
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc"
)
@ -330,3 +331,48 @@ func mapRowToResult(row sqlc.BacktestResult) (backtest.Result, error) {
EquityCurve: equityCurve,
}, nil
}
func mapAuditEventToParams(event trading.AuditEvent) (sqlc.AppendLiveAuditEventParams, error) {
payloadBytes, err := json.Marshal(event.Payload)
if err != nil {
return sqlc.AppendLiveAuditEventParams{}, fmt.Errorf("failed to marshal audit payload: %w", err)
}
return sqlc.AppendLiveAuditEventParams{
EventID: event.EventID,
AccountID: event.AccountID,
Broker: string(event.Broker),
OrderID: event.OrderID,
EventType: string(event.Type),
Status: event.Status,
Reason: event.Reason,
Actor: event.Actor,
CorrelationID: event.CorrelationID,
Payload: payloadBytes,
CreatedAt: pgtype.Timestamptz{Time: event.Timestamp, Valid: !event.Timestamp.IsZero()},
}, nil
}
func mapRowToAuditEvent(row sqlc.LiveAuditEvent) (trading.AuditEvent, error) {
var payload map[string]string
if len(row.Payload) > 0 {
if err := json.Unmarshal(row.Payload, &payload); err != nil {
return trading.AuditEvent{}, fmt.Errorf("failed to unmarshal audit payload: %w", err)
}
}
if payload == nil {
payload = make(map[string]string)
}
return trading.AuditEvent{
EventID: row.EventID,
Type: trading.AuditEventType(row.EventType),
Timestamp: row.CreatedAt.Time,
Broker: trading.Broker(row.Broker),
AccountID: row.AccountID,
OrderID: row.OrderID,
Status: row.Status,
Reason: row.Reason,
Actor: row.Actor,
CorrelationID: row.CorrelationID,
Payload: payload,
}, nil
}

View file

@ -1,11 +1,15 @@
package postgres
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/packages/domain/trading"
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc"
)
@ -339,6 +343,114 @@ func TestBacktestResultMappingRejectsInvalidDecimal(t *testing.T) {
}
}
func TestAuditEventMappingRoundTrip(t *testing.T) {
now := time.Now().UTC().Truncate(time.Microsecond)
ev := trading.AuditEvent{
EventID: "evt-001",
Type: trading.AuditEventTypeSubmitConfirmed,
Timestamp: now,
Broker: trading.BrokerKIS,
AccountID: "acct-1",
OrderID: "lo-acct-1-1",
Status: "submitted",
Reason: "operator confirmed",
Actor: "op-001",
CorrelationID: "corr-001",
Payload: map[string]string{"instrument_id": "005930.KS"},
}
params, err := mapAuditEventToParams(ev)
if err != nil {
t.Fatalf("mapAuditEventToParams failed: %v", err)
}
row := sqlc.LiveAuditEvent{
EventID: params.EventID,
AccountID: params.AccountID,
Broker: params.Broker,
OrderID: params.OrderID,
EventType: params.EventType,
Status: params.Status,
Reason: params.Reason,
Actor: params.Actor,
CorrelationID: params.CorrelationID,
Payload: params.Payload,
CreatedAt: pgtype.Timestamptz{Time: now, Valid: true},
}
got, err := mapRowToAuditEvent(row)
if err != nil {
t.Fatalf("mapRowToAuditEvent failed: %v", err)
}
if got.EventID != ev.EventID {
t.Errorf("EventID mismatch: got %q, want %q", got.EventID, ev.EventID)
}
if got.Type != ev.Type {
t.Errorf("Type mismatch: got %q, want %q", got.Type, ev.Type)
}
if got.AccountID != ev.AccountID {
t.Errorf("AccountID mismatch: got %q, want %q", got.AccountID, ev.AccountID)
}
if got.OrderID != ev.OrderID {
t.Errorf("OrderID mismatch: got %q, want %q", got.OrderID, ev.OrderID)
}
if got.Payload["instrument_id"] != ev.Payload["instrument_id"] {
t.Errorf("Payload mismatch: got %v, want %v", got.Payload, ev.Payload)
}
if !got.Timestamp.Equal(now) {
t.Errorf("Timestamp mismatch: got %v, want %v", got.Timestamp, now)
}
}
func TestAuditEventMappingRejectsRawSecretKey(t *testing.T) {
ev := trading.AuditEvent{
EventID: "evt-bad",
Type: trading.AuditEventTypeSubmitConfirmed,
AccountID: "acct-1",
Payload: map[string]string{"token": "raw-token-value"},
}
// mapAuditEventToParams itself does not block raw secrets; Store.AppendLiveAuditEvent
// is the enforcement boundary. The mapping must still preserve the payload bytes
// so the store-level check sees the original data.
params, err := mapAuditEventToParams(ev)
if err != nil {
t.Fatalf("mapAuditEventToParams failed: %v", err)
}
if len(params.Payload) == 0 {
t.Error("expected non-empty payload bytes after mapping")
}
// Verify HasRawSecretKey detects the forbidden key in the original payload.
if !trading.HasRawSecretKey(ev.Payload) {
t.Error("expected HasRawSecretKey=true for payload containing 'token'")
}
}
func TestStoreLiveAuditAppendRejectsForbiddenPayload(t *testing.T) {
// Store.AppendLiveAuditEvent must reject forbidden payload keys before
// touching the database. Using a nil-pool store so no DB is needed.
store := NewStore(nil)
forbidden := []map[string]string{
{"token": "abc"},
{"secret": "shh"},
{"password": "hunter2"},
{"raw_account_number": "123456"},
{"api_key": "key"},
}
for _, payload := range forbidden {
ev := trading.AuditEvent{
EventID: "evt-forbidden",
Type: trading.AuditEventTypeSubmitConfirmed,
AccountID: "acct-1",
Payload: payload,
}
err := store.AppendLiveAuditEvent(context.Background(), ev)
if err == nil {
t.Errorf("expected error for forbidden payload %v, got nil", payload)
}
}
}
func TestInstrumentMappingRoundTrip(t *testing.T) {
inst := market.Instrument{
ID: "NASDAQ:AAPL",

View file

@ -80,6 +80,25 @@ func TestMigrationStatementsAreNamed(t *testing.T) {
}
}
func TestLiveAuditEventsMigrationPairing(t *testing.T) {
up, err := migrationsFS.ReadFile("migrations/000004_live_audit_events.up.sql")
if err != nil {
t.Fatalf("failed to read 000004 up migration: %v", err)
}
down, err := migrationsFS.ReadFile("migrations/000004_live_audit_events.down.sql")
if err != nil {
t.Fatalf("failed to read 000004 down migration: %v", err)
}
for _, col := range []string{"event_id", "account_id", "event_type", "payload", "created_at"} {
if !strings.Contains(string(up), col) {
t.Errorf("000004 up migration missing column %q", col)
}
}
if !strings.Contains(string(down), "live_audit_events") {
t.Error("000004 down migration must drop live_audit_events table")
}
}
func TestAnalysisResultColumnsAddedInVersionedMigration(t *testing.T) {
// The analysis result columns must arrive through the v2 migration so databases
// already at version 1 receive them, rather than being edited into 000001.

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS live_audit_events;

View file

@ -0,0 +1,18 @@
CREATE TABLE live_audit_events (
event_id TEXT NOT NULL,
account_id TEXT NOT NULL,
broker TEXT NOT NULL DEFAULT '',
order_id TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL DEFAULT '',
correlation_id TEXT NOT NULL DEFAULT '',
payload JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (event_id)
);
CREATE INDEX live_audit_events_account_id_idx ON live_audit_events (account_id);
CREATE INDEX live_audit_events_order_id_idx ON live_audit_events (order_id) WHERE order_id != '';
CREATE INDEX live_audit_events_created_at_idx ON live_audit_events (created_at);

View file

@ -77,3 +77,15 @@ ON CONFLICT (run_id) DO UPDATE SET
SELECT run_id, starting_cash_currency, starting_cash_amount, ending_equity_currency, ending_equity_amount, trades, positions, total_return, trade_count, equity_curve
FROM backtest_results
WHERE run_id = $1;
-- name: AppendLiveAuditEvent :exec
INSERT INTO live_audit_events (event_id, account_id, broker, order_id, event_type, status, reason, actor, correlation_id, payload, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
-- name: ListLiveAuditEvents :many
SELECT event_id, account_id, broker, order_id, event_type, status, reason, actor, correlation_id, payload, created_at
FROM live_audit_events
WHERE (@account_id::text = '' OR account_id = @account_id::text)
AND (@order_id::text = '' OR order_id = @order_id::text)
AND (@event_type::text = '' OR event_type = @event_type::text)
ORDER BY created_at ASC;

View file

@ -54,3 +54,17 @@ type Instrument struct {
ProviderSymbols []byte
AssetType string
}
type LiveAuditEvent struct {
EventID string
AccountID string
Broker string
OrderID string
EventType string
Status string
Reason string
Actor string
CorrelationID string
Payload []byte
CreatedAt pgtype.Timestamptz
}

View file

@ -11,6 +11,42 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const appendLiveAuditEvent = `-- name: AppendLiveAuditEvent :exec
INSERT INTO live_audit_events (event_id, account_id, broker, order_id, event_type, status, reason, actor, correlation_id, payload, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type AppendLiveAuditEventParams struct {
EventID string
AccountID string
Broker string
OrderID string
EventType string
Status string
Reason string
Actor string
CorrelationID string
Payload []byte
CreatedAt pgtype.Timestamptz
}
func (q *Queries) AppendLiveAuditEvent(ctx context.Context, arg AppendLiveAuditEventParams) error {
_, err := q.db.Exec(ctx, appendLiveAuditEvent,
arg.EventID,
arg.AccountID,
arg.Broker,
arg.OrderID,
arg.EventType,
arg.Status,
arg.Reason,
arg.Actor,
arg.CorrelationID,
arg.Payload,
arg.CreatedAt,
)
return err
}
const getBars = `-- name: GetBars :many
SELECT instrument_id, timeframe, timestamp, open, high, low, close, volume
FROM bars
@ -162,6 +198,53 @@ func (q *Queries) ListInstruments(ctx context.Context) ([]Instrument, error) {
return items, nil
}
const listLiveAuditEvents = `-- name: ListLiveAuditEvents :many
SELECT event_id, account_id, broker, order_id, event_type, status, reason, actor, correlation_id, payload, created_at
FROM live_audit_events
WHERE ($1::text = '' OR account_id = $1::text)
AND ($2::text = '' OR order_id = $2::text)
AND ($3::text = '' OR event_type = $3::text)
ORDER BY created_at ASC
`
type ListLiveAuditEventsParams struct {
AccountID string
OrderID string
EventType string
}
func (q *Queries) ListLiveAuditEvents(ctx context.Context, arg ListLiveAuditEventsParams) ([]LiveAuditEvent, error) {
rows, err := q.db.Query(ctx, listLiveAuditEvents, arg.AccountID, arg.OrderID, arg.EventType)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LiveAuditEvent
for rows.Next() {
var i LiveAuditEvent
if err := rows.Scan(
&i.EventID,
&i.AccountID,
&i.Broker,
&i.OrderID,
&i.EventType,
&i.Status,
&i.Reason,
&i.Actor,
&i.CorrelationID,
&i.Payload,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRuns = `-- name: ListRuns :many
SELECT id, strategy_id, market, timeframe, from_time, to_time, status, created_at, updated_at
FROM backtest_runs

View file

@ -12,6 +12,7 @@ import (
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/packages/domain/trading"
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
"git.toki-labs.com/toki/alt/services/worker/internal/storage/postgres/sqlc"
)
@ -21,6 +22,7 @@ var _ storage.BarStore = (*Store)(nil)
var _ storage.BacktestRunStore = (*Store)(nil)
var _ storage.BacktestResultStore = (*Store)(nil)
var _ storage.BacktestAnalysisStore = (*Store)(nil)
var _ storage.LiveAuditStore = (*Store)(nil)
type Store struct {
pool *pgxpool.Pool
@ -214,3 +216,40 @@ func (s *Store) GetResult(ctx context.Context, id backtest.RunID) (backtest.Resu
}
return res, nil
}
func (s *Store) AppendLiveAuditEvent(ctx context.Context, event trading.AuditEvent) error {
if trading.HasRawSecretKey(event.Payload) {
return fmt.Errorf("audit payload contains forbidden key")
}
params, err := mapAuditEventToParams(event)
if err != nil {
return fmt.Errorf("failed to map audit event: %w", err)
}
if err := s.queries.AppendLiveAuditEvent(ctx, params); err != nil {
return fmt.Errorf("failed to append live audit event: %w", err)
}
return nil
}
func (s *Store) ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error) {
rows, err := s.queries.ListLiveAuditEvents(ctx, sqlc.ListLiveAuditEventsParams{
AccountID: filter.AccountID,
OrderID: filter.OrderID,
EventType: string(filter.EventType),
})
if err != nil {
return nil, fmt.Errorf("failed to list live audit events: %w", err)
}
events := make([]trading.AuditEvent, 0, len(rows))
for _, row := range rows {
ev, err := mapRowToAuditEvent(row)
if err != nil {
return nil, fmt.Errorf("failed to map audit event row: %w", err)
}
events = append(events, ev)
if filter.Limit > 0 && len(events) >= filter.Limit {
break
}
}
return events, nil
}

View file

@ -13,6 +13,7 @@ import (
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/packages/domain/trading"
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
)
@ -198,3 +199,90 @@ func indexOfRun(runs []backtest.Run, id backtest.RunID) int {
}
return -1
}
func TestStoreLiveAuditAppendAndList(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.Format("20060102T150405.000000000")
accountID := "acct-audit-" + suffix
orderID := "lo-" + accountID + "-1"
ev1 := trading.AuditEvent{
EventID: "evt-submit-" + suffix,
Type: trading.AuditEventTypeSubmitConfirmed,
Timestamp: now,
Broker: trading.BrokerKIS,
AccountID: accountID,
OrderID: orderID,
Status: "submitted",
Actor: "op-001",
Payload: map[string]string{"instrument_id": "005930.KS"},
}
ev2 := trading.AuditEvent{
EventID: "evt-broker-" + suffix,
Type: trading.AuditEventTypeBrokerAccepted,
Timestamp: now.Add(100 * time.Millisecond),
Broker: trading.BrokerKIS,
AccountID: accountID,
OrderID: orderID,
Status: "accepted",
Payload: map[string]string{"broker_order_id": "KIS-9999"},
}
ev3 := trading.AuditEvent{
EventID: "evt-risk-" + suffix,
Type: trading.AuditEventTypeRiskBlocked,
Timestamp: now.Add(200 * time.Millisecond),
Broker: trading.BrokerKIS,
AccountID: accountID,
OrderID: "lo-" + accountID + "-2",
Status: "blocked",
Payload: map[string]string{"reason": "kill_switch"},
}
for _, ev := range []trading.AuditEvent{ev1, ev2, ev3} {
if err := store.AppendLiveAuditEvent(ctx, ev); err != nil {
t.Fatalf("AppendLiveAuditEvent(%s) failed: %v", ev.EventID, err)
}
}
// list all events for account
all, err := store.ListLiveAuditEvents(ctx, trading.AuditFilter{AccountID: accountID})
if err != nil {
t.Fatalf("ListLiveAuditEvents failed: %v", err)
}
if len(all) != 3 {
t.Fatalf("expected 3 events, got %d", len(all))
}
// filter by order_id
byOrder, err := store.ListLiveAuditEvents(ctx, trading.AuditFilter{AccountID: accountID, OrderID: orderID})
if err != nil {
t.Fatalf("ListLiveAuditEvents by order_id failed: %v", err)
}
if len(byOrder) != 2 {
t.Fatalf("expected 2 events for order_id %s, got %d", orderID, len(byOrder))
}
// filter by event_type
byType, err := store.ListLiveAuditEvents(ctx, trading.AuditFilter{AccountID: accountID, EventType: trading.AuditEventTypeRiskBlocked})
if err != nil {
t.Fatalf("ListLiveAuditEvents by event_type failed: %v", err)
}
if len(byType) != 1 {
t.Fatalf("expected 1 risk_blocked event, got %d", len(byType))
}
if byType[0].Type != trading.AuditEventTypeRiskBlocked {
t.Errorf("expected event_type risk_blocked, got %q", byType[0].Type)
}
// limit
limited, err := store.ListLiveAuditEvents(ctx, trading.AuditFilter{AccountID: accountID, Limit: 1})
if err != nil {
t.Fatalf("ListLiveAuditEvents with limit failed: %v", err)
}
if len(limited) != 1 {
t.Fatalf("expected 1 event with limit=1, got %d", len(limited))
}
}