feat: backtest data collection infrastructure freshness status and operator updates

This commit is contained in:
toki 2026-06-14 17:59:01 +09:00
parent d1bfc28d34
commit 8d4cb785e8
19 changed files with 1155 additions and 41 deletions

View file

@ -40,37 +40,39 @@ task=m-backtest-data-collection-infrastructure/02+01_freshness_status, plan=0, t
| 항목 | 완료 여부 |
|------|---------|
| [CLI-1] Freshness Scenario Action | [ ] |
| [CLI-2] Freshness Runner And Output | [ ] |
| [CLI-3] Freshness Fixtures And Handoff | [ ] |
| [CLI-1] Freshness Scenario Action | [x] |
| [CLI-2] Freshness Runner And Output | [x] |
| [CLI-3] Freshness Fixtures And Handoff | [x] |
## 구현 체크리스트
- [ ] `collection_freshness` scenario action과 validation을 추가한다.
- [ ] Runner가 named universe를 기준으로 instruments/bars를 조회해 success/error/missing/latest 상태를 계산한다.
- [ ] Text/JSONL output과 fixtures/headless handoff 문서에 freshness 상태 키를 반영한다.
- [ ] `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `collection_freshness` scenario action과 validation을 추가한다.
- [x] Runner가 named universe를 기준으로 instruments/bars를 조회해 success/error/missing/latest 상태를 계산한다.
- [x] Text/JSONL output과 fixtures/headless handoff 문서에 freshness 상태 키를 반영한다.
- [x] `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리를 archive로 이동한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
시나리오 파일 `market_data_status_query.yaml`에 `collection_freshness` 단계가 추가됨에 따라, 해당 단계를 실행할 때 내부적으로 `ListInstruments`가 1회 추가 호출됩니다. 이로 인해 기존 테스트 `TestRunScenarioMarketDataStatusImportsBeforeReads`에서 기대하는 API call order가 변경되어, wantOrder를 `[import_daily_bars list_instruments list_bars list_instruments]`로 갱신해주었습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
`collection_freshness` 가 scenario validation 과정을 통과할 때 `universe` 필드가 Materialize 과정을 거쳐 symbols 등으로 분리된 이후에도 `universe` 필드의 값을 `validateRequest`와 `runStep` 단계에서 온전히 활용할 수 있도록, `applyUniverseDefaults`에서 `ActionCollectionFreshness` 액션인 경우 `step.Request.Universe` 필드를 비우지 않고 보존하도록 수정했습니다.
## 사용자 리뷰 요청
@ -92,36 +94,52 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
각 중간 검증 및 최종 검증 명령 실행 후 출력을 다음과 같이 첨부합니다.
### CLI-1 중간 검증
```bash
$ go test ./apps/cli/internal/operator
(output)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.184s
```
### CLI-2 중간 검증
```bash
$ go test ./apps/cli/internal/operator
(output)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.418s
```
### CLI-3 중간 검증
```bash
$ go test ./apps/cli/internal/operator
(output)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.186s
```
### 최종 검증
```bash
$ go test ./apps/cli/internal/operator ./apps/cli/internal/cli
(output)
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli 0.006s
```
## 코드리뷰 결과
_리뷰 에이전트가 PASS/WARN/FAIL 판정을 append한다._
### 2026-06-14 local-G06 review
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Pass
- Code quality: Pass
- Plan deviation: Fail
- Verification trust: Pass
- 발견된 문제:
- Required: `apps/cli/testdata/operator/expected/market_data_status_query.jsonl:5`와 `apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl:5`의 `collection_freshness` expected line이 `latest_yyyymmdd`를 전혀 고정하지 않고, 같은 fixture의 `list_bars` line은 `count=10`인데 freshness는 동일 universe symbol을 `missing_symbols`로 보고합니다. 이 때문에 operator-facing freshness handoff가 "최신 일자"를 검증하지 못하고, status scenario evidence가 서로 모순됩니다. fixture YAML 기대값, expected JSONL, `headless_validation.md`의 Expected output keys, 그리고 관련 runner test fake instrument/bar data를 갱신해 `freshness_status=fresh`, `missing_count=0`, `latest_yyyymmdd=<expected date>`가 실제로 고정되도록 수정하세요.
- 다음 단계: WARN/FAIL 후속 PLAN/CODE_REVIEW 파일을 생성한다.

View file

@ -0,0 +1,145 @@
<!-- task=m-backtest-data-collection-infrastructure/02+01_freshness_status plan=1 tag=REVIEW_CLI -->
# Code Review Reference - REVIEW_CLI
> **[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-14
task=m-backtest-data-collection-infrastructure/02+01_freshness_status, plan=1, tag=REVIEW_CLI
## Roadmap Targets
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
- Task ids:
- `freshness-status`: 수집 성공, 실패, 누락, 최신 일자를 operator가 확인할 수 있는 상태 출력을 만든다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` → `code_review_local_G07_N.log`, `PLAN-local-G07.md` → `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/02+01_freshness_status/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-backtest-data-collection-infrastructure`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_CLI-1] Freshness Fixture Latest Contract | [x] |
## 구현 체크리스트
- [x] `market_data_status_query`와 `us_market_data_status_query` freshness fixture 기대값과 runner fake instrument/bar data를 `freshness_status=fresh`, `missing_count=0`, `latest_yyyymmdd=<expected date>`로 맞춘다.
- [x] Expected JSONL fixture와 `headless_validation.md`의 Expected output keys에 `latest_yyyymmdd`를 포함해 matrix-driven key test가 최신 일자 키를 검증하게 한다.
- [x] 관련 tests가 text output과 JSONL/fixture 계약에서 freshness status, missing count, latest date를 의미 있게 확인하도록 보강한다.
- [x] `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G08.md`와 `CODE_REVIEW-local-G08.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로 이동한다.
## 계획 대비 변경 사항
`TestRunScenarioMarketDataStatusImportsBeforeReads` 테스트에서 `collection_freshness`가 `ListInstruments` 외에도 매칭된 instrument에 대해 `ListBars`를 추가로 호출하게 됨에 따라, 기대하는 API call order를 `[import_daily_bars list_instruments list_bars list_instruments list_bars]`로 갱신했습니다.
## 주요 설계 결정
status scenario의 `list_bars` 단계가 바를 성공적으로 가져오는 기존 팩트와 모순되지 않도록 `fakeAPI`의 `instResp`와 `barsResp`에 `Symbol` 및 `TimestampUnixMs`를 정의했습니다. 이를 통해 `collection_freshness` 단계가 정상적으로 성공(fresh, missing_count=0)하고 최신 일자(`latest_yyyymmdd`)를 정상 보고할 수 있도록 정합성을 확보했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- status scenario expected JSONL이 `list_bars count`와 모순되지 않는 freshness status를 담는지 확인한다.
- `latest_yyyymmdd`가 YAML expectation, expected JSONL fixture, handoff Expected output keys, tests에서 함께 고정되는지 확인한다.
- 새 API/worker/contracts 변경 없이 CLI/operator fixture와 test 범위만 수정됐는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW_CLI-1 중간 검증
```bash
$ go test ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.181s
```
### 최종 검증
```bash
$ go test ./apps/cli/internal/operator ./apps/cli/internal/cli
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli (cached)
```
---
> **[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.
## 코드리뷰 결과
### 2026-06-14 local-G07 review
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Pass
- Code quality: Pass
- Plan deviation: Fail
- Verification trust: Pass
- 발견된 문제:
- Required: `apps/cli/testdata/operator/market_data_status_query.yaml:52`의 `collection_freshness` query window는 `1746057600000..1747267200000`, 즉 `20250501..20250515` UTC인데, 같은 step의 expectation과 expected JSONL은 `latest_yyyymmdd: "20240501"`을 요구합니다. 이를 통과시키기 위해 `apps/cli/internal/operator/runner_market_test.go:51`의 fake bar timestamp도 `1714521600000`(`20240501`)로 설정되어 있어, 실제 worker/API가 요청 window를 지켜 반환하는 정상 조건과 맞지 않는 handoff evidence가 됩니다. KR fixture의 freshness window, fake bar timestamp, YAML expectation, expected JSONL, text assertion을 모두 같은 날짜 범위로 맞추세요. 예: 기존 request window를 유지한다면 latest를 `20250501`로 고정하고 fake timestamp도 `1746057600000`으로 바꿉니다.
- 다음 단계: WARN/FAIL 후속 PLAN/CODE_REVIEW 파일을 생성한다.

View file

@ -0,0 +1,150 @@
<!-- task=m-backtest-data-collection-infrastructure/02+01_freshness_status plan=2 tag=REVIEW2_CLI -->
# Code Review Reference - REVIEW2_CLI
> **[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-14
task=m-backtest-data-collection-infrastructure/02+01_freshness_status, plan=2, tag=REVIEW2_CLI
## Roadmap Targets
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
- Task ids:
- `freshness-status`: 수집 성공, 실패, 누락, 최신 일자를 operator가 확인할 수 있는 상태 출력을 만든다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G08.md` → `code_review_local_G08_N.log`, `PLAN-local-G08.md` → `plan_local_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-backtest-data-collection-infrastructure/02+01_freshness_status/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-backtest-data-collection-infrastructure`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW2_CLI-1] KR Freshness Window And Latest Date | [x] |
## 구현 체크리스트
- [x] KR `market_data_status_query` freshness request window, YAML `latest_yyyymmdd`, expected JSONL, runner text assertion, fake bar timestamp가 모두 같은 날짜 범위 안에서 일치하도록 수정한다.
- [x] 날짜 정합성을 검증하는 테스트나 assertion을 보강해 KR latest date가 request window 밖으로 다시 drift되지 않게 한다.
- [x] `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
없음.
## 주요 설계 결정
KR status scenario의 `collection_freshness` request window `1746057600000..1747267200000` (UTC 기준 `20250501..20250515`) 내에서 일치하도록 `fakeAPI`의 fake bar timestamp를 `1746057600000` (`20250501`)로 변경하였으며, 이에 맞춰 YAML expectation, expected JSONL, runner text assertion을 모두 `20250501`로 통일했습니다. 이로써 실제 API/worker의 window 필터 조건 및 수집 날짜의 정합성을 확보했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- KR fixture의 `from_unix_ms`/`to_unix_ms`, fake `TimestampUnixMs`, YAML `latest_yyyymmdd`, expected JSONL, text assertion이 모두 같은 날짜 범위 안에서 일치하는지 확인한다.
- US fixture를 불필요하게 변경하지 않았는지 확인한다.
- 새 API/worker/contracts 변경 없이 CLI/operator fixture와 test 범위만 수정됐는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### REVIEW2_CLI-1 중간 검증
```bash
$ go test ./apps/cli/internal/operator
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator 0.182s
```
### 최종 검증
```bash
$ go test ./apps/cli/internal/operator ./apps/cli/internal/cli
ok git.toki-labs.com/toki/alt/apps/cli/internal/operator (cached)
ok git.toki-labs.com/toki/alt/apps/cli/internal/cli (cached)
```
---
> **[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
- Required: 0
- Suggested: 0
- Nit: 0
### 차원별 평가
| 차원 | 판정 | 근거 |
|------|------|------|
| 요구사항 충족 | Pass | KR freshness request window, fake bar timestamp, YAML expectation, expected JSONL, runner text assertion이 모두 `20250501` 기준으로 정렬됨. |
| 정합성/회귀 위험 | Pass | stale `20240501` 참조가 KR status path에서 제거되었고, US fixture의 기존 latest date는 유지됨. |
| 검증 | Pass | `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli` 통과. |
### 발견된 문제
- 없음.
### 다음 단계
- PASS 종결: active plan/review를 log로 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,43 @@
# Complete - m-backtest-data-collection-infrastructure/02+01_freshness_status
## 완료 일시
2026-06-14
## 요약
`freshness-status` operator 상태 출력 작업을 3회 리뷰 루프 후 PASS로 종결했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | freshness expected JSONL의 `latest_yyyymmdd` 누락 및 `list_bars`/missing symbol 기대값 불일치 확인. |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | FAIL | KR freshness latest date가 request window 밖의 `20240501`로 남아 있어 날짜 정합성 실패. |
| `plan_local_G08_2.log` | `code_review_local_G08_2.log` | PASS | KR request window, fake bar timestamp, YAML expectation, expected JSONL, runner text assertion을 `20250501` 기준으로 정렬하고 검증 통과. |
## 구현/정리 내용
- operator scenario에 `collection_freshness` action과 freshness 검증 흐름을 추가했다.
- named universe 기준 instruments/bars 조회 후 freshness status, missing count, latest date를 상태 출력에 포함하도록 정리했다.
- text/JSONL 출력, status fixtures, handoff matrix에 `freshness_status`, `missing_count`, `latest_yyyymmdd` 계약을 반영했다.
- KR/US status fixtures의 latest date를 각 request window 안의 수집 일자와 일치시켰다.
## 최종 검증
- `go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli` - PASS; `operator` 0.186s, `cli` 0.005s.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
- Completed task ids:
- `freshness-status`: PASS; evidence=`agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G08_2.log`, `agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G08_2.log`; verification=`go test -count=1 ./apps/cli/internal/operator ./apps/cli/internal/cli`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,93 @@
<!-- task=m-backtest-data-collection-infrastructure/02+01_freshness_status plan=1 tag=REVIEW_CLI -->
# Plan - REVIEW_CLI Freshness Fixture Contract Repair
## 이 파일을 읽는 구현 에이전트에게
이 plan은 직전 `local-G06` 코드리뷰의 Required 이슈만 해결한다. 새 protobuf/API/worker/storage 변경은 범위 밖이다. 구현 후 `CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다. 종결, log rename, `complete.log`, archive 이동은 code-review 전용이다.
구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 범위 충돌이 필요하면 `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 멈춘다. 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
- Task ids:
- `freshness-status`: 수집 성공, 실패, 누락, 최신 일자를 operator가 확인할 수 있는 상태 출력을 만든다.
- Completion mode: check-on-pass
## 배경
`collection_freshness` runner 구현과 기본 테스트는 들어갔지만, status handoff fixture가 "최신 일자"를 JSONL 계약으로 고정하지 못했다. 더 큰 문제는 `market_data_status_query`와 `us_market_data_status_query` expected JSONL에서 같은 scenario가 `list_bars count=10`을 보여주면서 freshness line은 동일 universe symbol을 missing으로 보고한다는 점이다. operator가 이 fixture를 기준으로 보면 수집된 bar가 있는데 freshness는 누락이라는 모순된 상태를 받는다.
## 범위 결정 근거
- 기존 `collection_freshness` action, API read orchestration, protobuf contract는 유지한다.
- fixture, handoff 문서, runner test fake response/expectation만 수정한다.
- 같은 Milestone Task의 완료 조건을 향하므로 `Roadmap Targets`를 유지한다.
## 구현 체크리스트
- [ ] `market_data_status_query`와 `us_market_data_status_query` freshness fixture 기대값과 runner fake instrument/bar data를 `freshness_status=fresh`, `missing_count=0`, `latest_yyyymmdd=<expected date>`로 맞춘다.
- [ ] Expected JSONL fixture와 `headless_validation.md`의 Expected output keys에 `latest_yyyymmdd`를 포함해 matrix-driven key test가 최신 일자 키를 검증하게 한다.
- [ ] 관련 tests가 text output과 JSONL/fixture 계약에서 freshness status, missing count, latest date를 의미 있게 확인하도록 보강한다.
- [ ] `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_CLI-1] Freshness Fixture Latest Contract
#### 문제
직전 리뷰에서 확인한 Required 이슈:
- `apps/cli/testdata/operator/expected/market_data_status_query.jsonl:5`
- `apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl:5`
- `apps/cli/testdata/operator/headless_validation.md:33`
- `apps/cli/testdata/operator/headless_validation.md:34`
현재 expected JSONL은 `collection_freshness` line에 `latest_yyyymmdd`를 포함하지 않는다. 또한 status scenario의 `list_bars` line은 bar count를 보여주지만 freshness line은 같은 symbol을 `missing_symbols`로 기록한다.
#### 해결 방법
KR/US status scenario fixture는 수집된 bar가 있는 정상 상태의 handoff evidence로 정리한다.
Before:
```json
{"action":"collection_freshness","freshness_status":"missing","missing_count":1,"missing_symbols":["005930"],"scenario":"market_data_status_query","status":"ok","step":"freshness_status","type":"step","universe":"kr-core"}
```
After 예시:
```json
{"action":"collection_freshness","freshness_status":"fresh","latest_yyyymmdd":"20240501","missing_count":0,"scenario":"market_data_status_query","status":"ok","step":"freshness_status","type":"step","universe":"kr-core"}
```
구체 작업:
- `market_data_status_query.yaml`과 `us_market_data_status_query.yaml`의 freshness `expect`에 `freshness_status: fresh`, `missing_count: 0`, `latest_yyyymmdd`를 넣는다.
- `runner_market_test.go`의 status scenario fake `Instrument`에 `Symbol`을 채우고, `Bar.TimestampUnixMs`를 fixture expected date와 맞춘다.
- status scenario text assertions에 `freshness_status=fresh`, `missing_count=0`, `latest_yyyymmdd=...`를 추가한다.
- expected JSONL fixture의 freshness line을 fresh/latest로 갱신한다.
- `headless_validation.md`의 Expected output keys에 `latest_yyyymmdd`를 추가한다.
- 필요하면 hardcoded fallback `requiredScenarioKeys`도 같은 key를 포함하도록 갱신한다.
#### 테스트 결정
- 필수: `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`
- 중간 검증: `go test ./apps/cli/internal/operator`
#### 중간 검증
```bash
go test ./apps/cli/internal/operator
```
기대 결과: exit code 0.
## 최종 검증
```bash
go test ./apps/cli/internal/operator ./apps/cli/internal/cli
```
기대 결과: exit code 0. Go test cache output은 허용한다.

View file

@ -0,0 +1,78 @@
<!-- task=m-backtest-data-collection-infrastructure/02+01_freshness_status plan=2 tag=REVIEW2_CLI -->
# Plan - REVIEW2_CLI KR Freshness Date Alignment
## 이 파일을 읽는 구현 에이전트에게
이 plan은 직전 `local-G07` 코드리뷰의 Required 이슈만 해결한다. 새 protobuf/API/worker/storage 변경은 범위 밖이다. 구현 후 `CODE_REVIEW-local-G08.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다. 종결, log rename, `complete.log`, archive 이동은 code-review 전용이다.
구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 범위 충돌이 필요하면 `CODE_REVIEW-local-G08.md`의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 멈춘다. 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/backtest-loop/milestones/backtest-data-collection-infrastructure.md`
- Task ids:
- `freshness-status`: 수집 성공, 실패, 누락, 최신 일자를 operator가 확인할 수 있는 상태 출력을 만든다.
- Completion mode: check-on-pass
## 배경
G07에서 freshness status와 `latest_yyyymmdd` key는 고정됐지만 KR status fixture의 날짜 정합성이 아직 깨져 있다. `market_data_status_query.yaml`의 `collection_freshness` request window는 `1746057600000..1747267200000`이며 UTC 기준 `20250501..20250515`다. 그런데 expectation, expected JSONL, fake bar timestamp, text assertion은 `20240501`을 사용한다.
이 상태는 fake API가 query window를 강제하지 않아 테스트는 통과하지만, 실제 worker/API가 window를 지키는 정상 동작과 맞지 않는 handoff evidence다.
## 범위 결정 근거
- KR fixture/test date alignment만 수정한다.
- US fixture는 request window `20240528..20240530`, expected latest `20240529`, fake timestamp `20240529`로 이미 정합적이므로 건드리지 않는다.
- 같은 Milestone Task의 완료 조건을 향하므로 `Roadmap Targets`를 유지한다.
## 구현 체크리스트
- [ ] KR `market_data_status_query` freshness request window, YAML `latest_yyyymmdd`, expected JSONL, runner text assertion, fake bar timestamp가 모두 같은 날짜 범위 안에서 일치하도록 수정한다.
- [ ] 날짜 정합성을 검증하는 테스트나 assertion을 보강해 KR latest date가 request window 밖으로 다시 drift되지 않게 한다.
- [ ] `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW2_CLI-1] KR Freshness Window And Latest Date
#### 문제
현재 상태:
- `apps/cli/testdata/operator/market_data_status_query.yaml:52`: request window starts at `1746057600000` = `20250501` UTC.
- `apps/cli/testdata/operator/market_data_status_query.yaml:58`: expected `latest_yyyymmdd: "20240501"`.
- `apps/cli/testdata/operator/expected/market_data_status_query.jsonl:5`: expected JSONL latest is `20240501`.
- `apps/cli/internal/operator/runner_market_test.go:51`: fake bar timestamp is `1714521600000` = `20240501`.
#### 해결 방법
가장 작은 수정은 기존 fixture request window를 유지하고 latest 값을 `20250501`로 정렬하는 것이다.
- `runner_market_test.go` KR status fake bar timestamp: `1714521600000` → `1746057600000`.
- KR status text assertion: `latest_yyyymmdd=20240501` → `latest_yyyymmdd=20250501`.
- `market_data_status_query.yaml` freshness expect: `latest_yyyymmdd: "20240501"` → `"20250501"`.
- `market_data_status_query.jsonl` freshness line: `"latest_yyyymmdd":"20240501"` → `"20250501"`.
선택적으로, 테스트에서 KR fixture의 latest date가 request window 안에 있는지도 고정한다. 단, 범위를 넓혀 새 parser/contract를 만들지는 않는다.
#### 테스트 결정
- 필수: `go test ./apps/cli/internal/operator ./apps/cli/internal/cli`
- 중간 검증: `go test ./apps/cli/internal/operator`
#### 중간 검증
```bash
go test ./apps/cli/internal/operator
```
기대 결과: exit code 0.
## 최종 검증
```bash
go test ./apps/cli/internal/operator ./apps/cli/internal/cli
```
기대 결과: exit code 0. Go test cache output은 허용한다.

View file

@ -46,6 +46,7 @@ type fakeAPI struct {
listLiveAuditEventsResp *altv1.ListLiveAuditEventsResponse
getBacktestRunFunc func(req *altv1.GetBacktestRunRequest) (*altv1.GetBacktestRunResponse, error)
barsRespFunc func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error)
mu sync.Mutex
instReq *altv1.ListInstrumentsRequest
@ -349,6 +350,9 @@ func startFakeAPIServer(t *testing.T, api *fakeAPI) string {
protoSocket.AddRequestListenerTyped[*altv1.ListBarsRequest, *altv1.ListBarsResponse](&c.Communicator, func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
api.recordCall("list_bars")
api.setBarsReq(req)
if api.barsRespFunc != nil {
return api.barsRespFunc(req)
}
if api.barsResp != nil {
return api.barsResp, nil
}

View file

@ -237,8 +237,8 @@ var requiredScenarioKeys = map[string][]string{
"backtest_run_request": {"run_id", "run_status", "bar_count", "instrument_count", "provider"},
"invalid_request_matrix": {"scenario", "status", "type", "action", "error_code", "error_message"},
"kis_daily_import_smoke": {"provider", "instrument_count", "bar_count"},
"market_data_status_query": {"provider", "instrument_count", "bar_count", "count"},
"us_market_data_status_query": {"provider", "instrument_count", "bar_count", "count"},
"market_data_status_query": {"provider", "instrument_count", "bar_count", "count", "universe", "freshness_status", "missing_count", "latest_yyyymmdd"},
"us_market_data_status_query": {"provider", "instrument_count", "bar_count", "count", "universe", "freshness_status", "missing_count", "latest_yyyymmdd"},
"paper_trading_state": {"account_id", "run_id", "run_status", "cash", "equity_point_count", "fill_count", "latest_equity", "position_count", "risk"},
"paper_order_lifecycle": {"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"},
"live_order_lifecycle": {"scenario", "status", "type", "action", "live_order_id", "live_order_status"},

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
)
// OutputFormat selects how runner events are rendered to stdout.
@ -43,6 +44,13 @@ type StepEvent struct {
InstrumentCount int
BarCount int
// Freshness-specific output fields
Universe string
FreshnessStatus string
LatestYYYYMMDD string
MissingCount int
MissingSymbols []string
// Paper-trading-specific output fields. They are only rendered when
// AccountID is set, so non-paper steps never emit them.
AccountID string
@ -179,6 +187,21 @@ func (o *Writer) WriteStep(ev StepEvent) {
if ev.BarCount >= 0 {
fields["bar_count"] = ev.BarCount
}
if ev.Universe != "" {
fields["universe"] = ev.Universe
}
if ev.FreshnessStatus != "" {
fields["freshness_status"] = ev.FreshnessStatus
}
if ev.LatestYYYYMMDD != "" {
fields["latest_yyyymmdd"] = ev.LatestYYYYMMDD
}
if ev.MissingCount >= 0 {
fields["missing_count"] = ev.MissingCount
}
if len(ev.MissingSymbols) > 0 {
fields["missing_symbols"] = ev.MissingSymbols
}
if ev.AccountID != "" {
fields["account_id"] = ev.AccountID
if ev.Cash != "" {
@ -301,6 +324,21 @@ func (o *Writer) WriteStep(ev StepEvent) {
if ev.BarCount >= 0 {
line += fmt.Sprintf(" bar_count=%d", ev.BarCount)
}
if ev.Universe != "" {
line += fmt.Sprintf(" universe=%s", ev.Universe)
}
if ev.FreshnessStatus != "" {
line += fmt.Sprintf(" freshness_status=%s", ev.FreshnessStatus)
}
if ev.LatestYYYYMMDD != "" {
line += fmt.Sprintf(" latest_yyyymmdd=%s", ev.LatestYYYYMMDD)
}
if ev.MissingCount >= 0 {
line += fmt.Sprintf(" missing_count=%d", ev.MissingCount)
}
if len(ev.MissingSymbols) > 0 {
line += fmt.Sprintf(" missing_symbols=%s", strings.Join(ev.MissingSymbols, ","))
}
if ev.AccountID != "" {
line += fmt.Sprintf(" account_id=%s", ev.AccountID)
if ev.Cash != "" {

View file

@ -213,9 +213,13 @@ func runStep(ctx context.Context, client *APIClient, scenario string, step Step,
Count: noCount,
InstrumentCount: noCount,
BarCount: noCount,
MissingCount: noCount,
}
switch step.Action {
case ActionCollectionFreshness:
return runCollectionFreshness(stepCtx, client, scenario, step)
case ActionHello:
resp, err := client.Hello(stepCtx, &altv1.HelloRequest{
ClientName: clientName,
@ -1137,3 +1141,153 @@ func interpolateSlice(slice []string, savedValues map[string]string) []string {
}
return res
}
func runCollectionFreshness(ctx context.Context, client *APIClient, scenario string, step Step) (StepEvent, int, string) {
ev := StepEvent{
Scenario: scenario,
Step: step.ID,
Action: string(step.Action),
Count: noCount,
InstrumentCount: noCount,
BarCount: noCount,
MissingCount: noCount,
}
resp, err := client.ListInstruments(ctx, &altv1.ListInstrumentsRequest{
Market: marketByName[step.Request.Market],
Provider: step.Request.Provider,
})
if err != nil {
ev.Status = statusTransportError
ev.ErrorMessage = err.Error()
return ev, codeTransport, ""
}
if resp.GetError() != nil {
return evaluateFreshnessError(ev, step, resp.GetError())
}
instBySymbol := make(map[string]*altv1.Instrument)
for _, inst := range resp.GetInstruments() {
if inst.GetSymbol() != "" {
instBySymbol[inst.GetSymbol()] = inst
}
if inst.GetProviderSymbols() != nil && step.Request.Provider != "" {
if psym, ok := inst.GetProviderSymbols()[step.Request.Provider]; ok && psym != "" {
instBySymbol[psym] = inst
}
}
}
var missingSymbols []string
var matchedInstruments []*altv1.Instrument
matchedSymbolMap := make(map[*altv1.Instrument]string)
for _, sym := range step.Request.Symbols {
if inst, ok := instBySymbol[sym]; ok {
matchedInstruments = append(matchedInstruments, inst)
matchedSymbolMap[inst] = sym
} else {
missingSymbols = append(missingSymbols, sym)
}
}
var latestUnixMs int64 = 0
for _, inst := range matchedInstruments {
barsResp, err := client.ListBars(ctx, &altv1.ListBarsRequest{
InstrumentId: inst.GetId(),
Timeframe: timeframeByName[step.Request.Timeframe],
FromUnixMs: step.Request.FromUnixMs,
ToUnixMs: step.Request.ToUnixMs,
})
if err != nil {
ev.Status = statusTransportError
ev.ErrorMessage = err.Error()
return ev, codeTransport, ""
}
if barsResp.GetError() != nil {
return evaluateFreshnessError(ev, step, barsResp.GetError())
}
instRequestSymbol := matchedSymbolMap[inst]
if instRequestSymbol == "" {
instRequestSymbol = inst.GetSymbol()
}
if len(barsResp.GetBars()) == 0 {
missingSymbols = append(missingSymbols, instRequestSymbol)
} else {
for _, bar := range barsResp.GetBars() {
if bar.GetTimestampUnixMs() > latestUnixMs {
latestUnixMs = bar.GetTimestampUnixMs()
}
}
}
}
ev.Universe = step.Request.Universe
ev.MissingSymbols = missingSymbols
ev.MissingCount = len(missingSymbols)
if latestUnixMs > 0 {
t := time.UnixMilli(latestUnixMs).UTC()
ev.LatestYYYYMMDD = t.Format("20060102")
}
if len(missingSymbols) == 0 {
ev.FreshnessStatus = "fresh"
} else {
ev.FreshnessStatus = "missing"
}
expectsError := step.Expect.Status == statusError
if expectsError {
ev.Status = statusMismatch
ev.ErrorMessage = "expected a typed error but the freshness check succeeded"
return ev, codeAppError, ""
}
if step.Expect.FreshnessStatus != "" && ev.FreshnessStatus != step.Expect.FreshnessStatus {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("expected freshness_status %q, got %q", step.Expect.FreshnessStatus, ev.FreshnessStatus)
return ev, codeAppError, ""
}
if step.Expect.MissingCount != nil && ev.MissingCount != *step.Expect.MissingCount {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("expected missing_count %d, got %d", *step.Expect.MissingCount, ev.MissingCount)
return ev, codeAppError, ""
}
if step.Expect.LatestYYYYMMDD != "" && ev.LatestYYYYMMDD != step.Expect.LatestYYYYMMDD {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("expected latest_yyyymmdd %q, got %q", step.Expect.LatestYYYYMMDD, ev.LatestYYYYMMDD)
return ev, codeAppError, ""
}
ev.Status = statusOK
return ev, codeOK, ""
}
func evaluateFreshnessError(ev StepEvent, step Step, errInfo *altv1.ErrorInfo) (StepEvent, int, string) {
expectsError := step.Expect.Status == statusError
ev.FreshnessStatus = "error"
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, ""
}
if step.Expect.FreshnessStatus != "" && step.Expect.FreshnessStatus != "error" {
ev.Status = statusMismatch
ev.ErrorMessage = fmt.Sprintf("expected freshness_status %q, got %q", step.Expect.FreshnessStatus, "error")
return ev, codeAppError, ""
}
ev.Status = statusOK
return ev, codeOK, ""
}

View file

@ -47,8 +47,8 @@ func TestRunScenarioMarketDataStatus(t *testing.T) {
url := startFakeAPIServer(t, &fakeAPI{
helloCaps: []string{"market-read"},
importDailyBarsResp: &altv1.ImportDailyBarsResponse{Provider: "kis", InstrumentCount: 1, BarCount: 42},
instResp: &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "KRX:005930"}}},
barsResp: &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930"}}},
instResp: &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "KRX:005930", Symbol: "005930"}}},
barsResp: &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1746057600000}}}, // 20250501
})
sc, err := LoadScenario(fixture(t, "market_data_status_query.yaml"))
if err != nil {
@ -74,6 +74,12 @@ func TestRunScenarioMarketDataStatus(t *testing.T) {
"action=list_bars",
"status=ok",
"count=1",
"step=freshness_status",
"action=collection_freshness",
"universe=kr-core",
"freshness_status=fresh",
"missing_count=0",
"latest_yyyymmdd=20250501",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
@ -85,8 +91,8 @@ func TestRunScenarioMarketDataStatusUS(t *testing.T) {
api := &fakeAPI{
helloCaps: []string{"market-read"},
importDailyBarsResp: &altv1.ImportDailyBarsResponse{Provider: "kis", InstrumentCount: 1, BarCount: 42},
instResp: &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "NASDAQ:AAPL"}}},
barsResp: &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "NASDAQ:AAPL"}}},
instResp: &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "NASDAQ:AAPL", Symbol: "AAPL"}}},
barsResp: &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "NASDAQ:AAPL", TimestampUnixMs: 1716940800000}}}, // 20240529
}
url := startFakeAPIServer(t, api)
sc, err := LoadScenario(fixture(t, "us_market_data_status_query.yaml"))
@ -113,6 +119,12 @@ func TestRunScenarioMarketDataStatusUS(t *testing.T) {
"action=list_bars",
"status=ok",
"count=1",
"step=freshness_status",
"action=collection_freshness",
"universe=us-core",
"freshness_status=fresh",
"missing_count=0",
"latest_yyyymmdd=20240529",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
@ -151,8 +163,8 @@ func TestRunScenarioMarketDataStatusImportsBeforeReads(t *testing.T) {
api := &fakeAPI{
helloCaps: []string{"market-read"},
importDailyBarsResp: &altv1.ImportDailyBarsResponse{Provider: "kis", InstrumentCount: 1, BarCount: 42},
instResp: &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "KRX:005930"}}},
barsResp: &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930"}}},
instResp: &altv1.ListInstrumentsResponse{Instruments: []*altv1.Instrument{{Id: "KRX:005930", Symbol: "005930"}}},
barsResp: &altv1.ListBarsResponse{Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1746057600000}}}, // 20250501
}
url := startFakeAPIServer(t, api)
sc, err := LoadScenario(fixture(t, "market_data_status_query.yaml"))
@ -190,7 +202,7 @@ func TestRunScenarioMarketDataStatusImportsBeforeReads(t *testing.T) {
}
// Call order: import precedes both status reads.
wantOrder := []string{"import_daily_bars", "list_instruments", "list_bars"}
wantOrder := []string{"import_daily_bars", "list_instruments", "list_bars", "list_instruments", "list_bars"}
if got := api.callOrder(); !equalStrings(got, wantOrder) {
t.Errorf("call order = %v, want %v (out=%q)", got, wantOrder, out)
}
@ -529,3 +541,216 @@ func itoa(n int) string {
}
return string(digits)
}
func TestRunScenarioCollectionFreshnessFresh(t *testing.T) {
api := &fakeAPI{
helloCaps: []string{"market-read"},
instResp: &altv1.ListInstrumentsResponse{
Instruments: []*altv1.Instrument{
{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
},
},
barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
if req.GetInstrumentId() == "KRX:005930" {
return &altv1.ListBarsResponse{
Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1716768000000}}, // 20240527
}, nil
}
if req.GetInstrumentId() == "KRX:000660" {
return &altv1.ListBarsResponse{
Bars: []*altv1.Bar{{InstrumentId: "KRX:000660", TimestampUnixMs: 1716854400000}}, // 20240528
}, nil
}
return &altv1.ListBarsResponse{}, nil
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "collection_freshness_fresh",
Universes: []UniverseConfig{{
Name: "kr-core",
Provider: "kis",
SelectorKind: "watchlist",
Market: "kr",
Venue: "krx",
Symbols: []string{"005930", "000660"},
}},
Steps: []Step{{
ID: "freshness1",
Action: ActionCollectionFreshness,
Request: Request{
Universe: "kr-core",
Timeframe: "daily",
FromUnixMs: 1716768000000,
ToUnixMs: 1716854400000,
},
Expect: Expect{
Status: "ok",
FreshnessStatus: "fresh",
MissingCount: intPtr(0),
LatestYYYYMMDD: "20240528",
},
}},
}
if err := sc.Validate(); err != nil {
t.Fatalf("scenario validate: %v", err)
}
out, code := runScenario(t, sc, url)
if code != codeOK {
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
}
for _, want := range []string{
"scenario=collection_freshness_fresh",
"step=freshness1",
"action=collection_freshness",
"status=ok",
"universe=kr-core",
"freshness_status=fresh",
"missing_count=0",
"latest_yyyymmdd=20240528",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
}
func TestRunScenarioCollectionFreshnessMissingSymbol(t *testing.T) {
api := &fakeAPI{
helloCaps: []string{"market-read"},
instResp: &altv1.ListInstrumentsResponse{
Instruments: []*altv1.Instrument{
{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
{Id: "KRX:000660", Symbol: "000660", Market: altv1.Market_MARKET_KR},
},
},
barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
if req.GetInstrumentId() == "KRX:005930" {
return &altv1.ListBarsResponse{
Bars: []*altv1.Bar{{InstrumentId: "KRX:005930", TimestampUnixMs: 1716768000000}}, // 20240527
}, nil
}
return &altv1.ListBarsResponse{}, nil
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "collection_freshness_missing",
Universes: []UniverseConfig{{
Name: "kr-core",
Provider: "kis",
SelectorKind: "watchlist",
Market: "kr",
Venue: "krx",
Symbols: []string{"005930", "000660", "000020"},
}},
Steps: []Step{{
ID: "freshness1",
Action: ActionCollectionFreshness,
Request: Request{
Universe: "kr-core",
Timeframe: "daily",
FromUnixMs: 1716768000000,
ToUnixMs: 1716854400000,
},
Expect: Expect{
Status: "ok",
FreshnessStatus: "missing",
MissingCount: intPtr(2),
LatestYYYYMMDD: "20240527",
},
}},
}
if err := sc.Validate(); err != nil {
t.Fatalf("scenario validate: %v", err)
}
out, code := runScenario(t, sc, url)
if code != codeOK {
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
}
for _, want := range []string{
"scenario=collection_freshness_missing",
"step=freshness1",
"action=collection_freshness",
"status=ok",
"universe=kr-core",
"freshness_status=missing",
"missing_count=2",
"missing_symbols=000020,000660",
"latest_yyyymmdd=20240527",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
}
func TestRunScenarioCollectionFreshnessTypedError(t *testing.T) {
api := &fakeAPI{
helloCaps: []string{"market-read"},
instResp: &altv1.ListInstrumentsResponse{
Instruments: []*altv1.Instrument{
{Id: "KRX:005930", Symbol: "005930", Market: altv1.Market_MARKET_KR},
},
},
barsRespFunc: func(req *altv1.ListBarsRequest) (*altv1.ListBarsResponse, error) {
return &altv1.ListBarsResponse{
Error: &altv1.ErrorInfo{Code: "invalid_request", Message: "bar service unavailable"},
}, nil
},
}
url := startFakeAPIServer(t, api)
sc := &Scenario{
Name: "collection_freshness_error",
Universes: []UniverseConfig{{
Name: "kr-core",
Provider: "kis",
SelectorKind: "watchlist",
Market: "kr",
Venue: "krx",
Symbols: []string{"005930"},
}},
Steps: []Step{{
ID: "freshness1",
Action: ActionCollectionFreshness,
Request: Request{
Universe: "kr-core",
Timeframe: "daily",
FromUnixMs: 1716768000000,
ToUnixMs: 1716854400000,
},
Expect: Expect{
Status: "error",
FreshnessStatus: "error",
ErrorCode: "invalid_request",
},
}},
}
if err := sc.Validate(); err != nil {
t.Fatalf("scenario validate: %v", err)
}
out, code := runScenario(t, sc, url)
if code != codeOK {
t.Fatalf("exit code = %d, want 0 (out=%q)", code, out)
}
for _, want := range []string{
"scenario=collection_freshness_error",
"step=freshness1",
"action=collection_freshness",
"status=ok",
"freshness_status=error",
"error.code=invalid_request",
"error.message=\"bar service unavailable\"",
} {
if !strings.Contains(out, want) {
t.Errorf("output %q missing %q", out, want)
}
}
}

View file

@ -23,6 +23,8 @@ type Action string
const (
// ActionHello validates a basic API connection handshake.
ActionHello Action = "hello"
// ActionCollectionFreshness validates a freshness check of collected data.
ActionCollectionFreshness Action = "collection_freshness"
// ActionImportDailyBars imports market data daily bars.
ActionImportDailyBars Action = "import_daily_bars"
// ActionListInstruments lists market instruments through the API.
@ -77,6 +79,7 @@ const (
// validActions is the closed set used for strict dry-run validation.
var validActions = map[Action]bool{
ActionHello: true,
ActionCollectionFreshness: true,
ActionImportDailyBars: true,
ActionListInstruments: true,
ActionListBars: true,
@ -163,6 +166,14 @@ var validTimeframes = map[string]bool{
"minute_5": true,
}
// validFreshnessStatuses defines the set of expected freshness status values.
var validFreshnessStatuses = map[string]bool{
"": true,
"fresh": true,
"missing": true,
"error": true,
}
// validBacktestStatuses contains valid backtest run status values for the dry-run check.
var validBacktestStatuses = map[string]bool{
"": true,
@ -236,6 +247,13 @@ type Expect struct {
TransportStatus string `yaml:"transport_status"`
// ExitCode expects a specific runner exit code.
ExitCode *int `yaml:"exit_code"`
// FreshnessStatus expects a specific freshness status ("fresh", "missing", "error").
FreshnessStatus string `yaml:"freshness_status"`
// MissingCount expects a specific number of missing symbols.
MissingCount *int `yaml:"missing_count"`
// LatestYYYYMMDD expects a specific latest date.
LatestYYYYMMDD string `yaml:"latest_yyyymmdd"`
}
// Request carries the per-action parameters for market and backtest steps. Fields not
@ -452,7 +470,7 @@ func (s *Scenario) Validate() error {
}
func applyUniverseDefaults(step *Step, universeByName map[string]UniverseConfig) error {
if step.Action != ActionImportDailyBars {
if step.Action != ActionImportDailyBars && step.Action != ActionCollectionFreshness {
return nil
}
if step.Request.Universe == "" {
@ -465,13 +483,15 @@ func applyUniverseDefaults(step *Step, universeByName map[string]UniverseConfig)
}
// Check inline conflicts
if step.Request.Provider != "" ||
step.Request.SelectorKind != "" ||
step.Request.Market != "" ||
step.Request.Venue != "" ||
step.Request.Name != "" ||
len(step.Request.Symbols) > 0 {
return fmt.Errorf("step %q: cannot specify both universe reference %q and inline import fields", step.ID, step.Request.Universe)
if step.Action == ActionImportDailyBars {
if step.Request.Provider != "" ||
step.Request.SelectorKind != "" ||
step.Request.Market != "" ||
step.Request.Venue != "" ||
step.Request.Name != "" ||
len(step.Request.Symbols) > 0 {
return fmt.Errorf("step %q: cannot specify both universe reference %q and inline import fields", step.ID, step.Request.Universe)
}
}
// Materialize fields
@ -481,7 +501,9 @@ func applyUniverseDefaults(step *Step, universeByName map[string]UniverseConfig)
step.Request.Venue = u.Venue
step.Request.Name = u.Name
step.Request.Symbols = append([]string(nil), u.Symbols...)
step.Request.Universe = ""
if step.Action == ActionImportDailyBars {
step.Request.Universe = ""
}
return nil
}
@ -509,6 +531,12 @@ func validateExpect(step Step) error {
if step.Expect.OrderStatus != "" && !validPaperOrderStatuses[step.Expect.OrderStatus] {
return fmt.Errorf("step %q: unsupported expect.order_status %q", step.ID, step.Expect.OrderStatus)
}
if step.Expect.FreshnessStatus != "" && !validFreshnessStatuses[step.Expect.FreshnessStatus] {
return fmt.Errorf("step %q: unsupported expect.freshness_status %q (want \"fresh\", \"missing\", or \"error\")", step.ID, step.Expect.FreshnessStatus)
}
if step.Expect.MissingCount != nil && *step.Expect.MissingCount < 0 {
return fmt.Errorf("step %q: expect.missing_count cannot be negative", step.ID)
}
return nil
}
@ -517,6 +545,19 @@ func validateExpect(step Step) error {
// fields are malformed.
func validateRequest(step Step) error {
switch step.Action {
case ActionCollectionFreshness:
if step.Request.Universe == "" {
return fmt.Errorf("step %q: collection_freshness requires request.universe", step.ID)
}
if !validTimeframes[step.Request.Timeframe] {
return fmt.Errorf("step %q: unsupported timeframe %q", step.ID, step.Request.Timeframe)
}
if step.Request.FromUnixMs == 0 || step.Request.ToUnixMs == 0 {
return fmt.Errorf("step %q: collection_freshness requires request.from_unix_ms and request.to_unix_ms", step.ID)
}
if step.Request.FromUnixMs > step.Request.ToUnixMs {
return fmt.Errorf("step %q: request.from_unix_ms cannot be after request.to_unix_ms", step.ID)
}
case ActionListInstruments:
if !validMarkets[step.Request.Market] {
return fmt.Errorf("step %q: unsupported market %q", step.ID, step.Request.Market)

View file

@ -72,6 +72,28 @@ func TestValidateRejectsUnknownExpectStatus(t *testing.T) {
}
}
func TestValidateRejectsUnknownFreshnessStatus(t *testing.T) {
data := []byte("name: bad_freshness_status\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n freshness_status: invalid_status\n")
_, err := ParseScenario(data)
if err == nil {
t.Fatal("expected error for unknown expect.freshness_status, got nil")
}
if !strings.Contains(err.Error(), "expect.freshness_status") {
t.Errorf("error = %v, want it to mention expect.freshness_status", err)
}
}
func TestValidateRejectsNegativeMissingCount(t *testing.T) {
data := []byte("name: bad_missing_count\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n missing_count: -1\n")
_, err := ParseScenario(data)
if err == nil {
t.Fatal("expected error for negative expect.missing_count, got nil")
}
if !strings.Contains(err.Error(), "expect.missing_count") {
t.Errorf("error = %v, want it to mention expect.missing_count", err)
}
}
func TestValidateRejectsErrorCodeWithoutErrorStatus(t *testing.T) {
data := []byte("name: bad_code\ntimeout: 1s\nsteps:\n - id: hello\n action: hello\n expect:\n status: ok\n error_code: invalid_request\n")
_, err := ParseScenario(data)
@ -398,6 +420,83 @@ steps:
`,
expectedErr: "requires request.to_yyyymmdd",
},
{
name: "freshness_missing_universe",
yaml: `
name: freshness_missing_universe
timeout: 1s
steps:
- id: step1
action: collection_freshness
request:
timeframe: daily
from_unix_ms: 100
to_unix_ms: 200
`,
expectedErr: "requires request.universe",
},
{
name: "freshness_invalid_timeframe",
yaml: `
name: freshness_invalid_timeframe
timeout: 1s
universes:
- name: kr-core
provider: kis
selector_kind: watchlist
symbols: ["005930"]
steps:
- id: step1
action: collection_freshness
request:
universe: kr-core
timeframe: invalid
from_unix_ms: 100
to_unix_ms: 200
`,
expectedErr: "unsupported timeframe",
},
{
name: "freshness_missing_unix_ms",
yaml: `
name: freshness_missing_unix_ms
timeout: 1s
universes:
- name: kr-core
provider: kis
selector_kind: watchlist
symbols: ["005930"]
steps:
- id: step1
action: collection_freshness
request:
universe: kr-core
timeframe: daily
from_unix_ms: 100
`,
expectedErr: "requires request.from_unix_ms and request.to_unix_ms",
},
{
name: "freshness_reversed_dates",
yaml: `
name: freshness_reversed_dates
timeout: 1s
universes:
- name: kr-core
provider: kis
selector_kind: watchlist
symbols: ["005930"]
steps:
- id: step1
action: collection_freshness
request:
universe: kr-core
timeframe: daily
from_unix_ms: 200
to_unix_ms: 100
`,
expectedErr: "cannot be after request.to_unix_ms",
},
}
for _, tt := range tests {

View file

@ -2,4 +2,5 @@
{"action":"import_daily_bars","bar_count":42,"instrument_count":1,"provider":"kis","scenario":"market_data_status_query","status":"ok","step":"import_kr_daily_bars","type":"step"}
{"action":"list_instruments","count":1,"scenario":"market_data_status_query","status":"ok","step":"list_kr_instruments","type":"step"}
{"action":"list_bars","count":10,"scenario":"market_data_status_query","status":"ok","step":"list_daily_bars","type":"step"}
{"exit_code":0,"passed":4,"scenario":"market_data_status_query","status":"ok","steps":4,"type":"summary"}
{"action":"collection_freshness","freshness_status":"fresh","latest_yyyymmdd":"20250501","missing_count":0,"scenario":"market_data_status_query","status":"ok","step":"freshness_status","type":"step","universe":"kr-core"}
{"exit_code":0,"passed":5,"scenario":"market_data_status_query","status":"ok","steps":5,"type":"summary"}

View file

@ -2,4 +2,5 @@
{"action":"import_daily_bars","bar_count":42,"instrument_count":1,"provider":"kis","scenario":"us_market_data_status_query","status":"ok","step":"import_us_daily_bars","type":"step"}
{"action":"list_instruments","count":1,"scenario":"us_market_data_status_query","status":"ok","step":"list_us_instruments","type":"step"}
{"action":"list_bars","count":10,"scenario":"us_market_data_status_query","status":"ok","step":"list_us_daily_bars","type":"step"}
{"exit_code":0,"passed":4,"scenario":"us_market_data_status_query","status":"ok","steps":4,"type":"summary"}
{"action":"collection_freshness","freshness_status":"fresh","latest_yyyymmdd":"20240529","missing_count":0,"scenario":"us_market_data_status_query","status":"ok","step":"freshness_status","type":"step","universe":"us-core"}
{"exit_code":0,"passed":5,"scenario":"us_market_data_status_query","status":"ok","steps":5,"type":"summary"}

View file

@ -30,8 +30,8 @@ ALT 운영 UI 게이트(`agent-ops/rules/project/rules.md`의 "운영 UI 구현
| Scenario | Command | Input fixture | Expected output fixture | Expected output keys | Expected exit code | Repeatable operation | UI candidate | Checked protobuf/view-model field | UI defer reason |
|-----|-|---|-|-|--|-|-|-|-|
| `api_connection_smoke` | `alt operator scenario run --file testdata/operator/api_connection_smoke.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/api_connection_smoke.yaml` | `testdata/operator/expected/api_connection_smoke.jsonl` | `scenario`, `status`, `type`, `action` | `0` (성공), `3` (transport 실패 시) | yes | connection status display | `HelloResponse.capabilities`; summary `status`/`exit_code` | Flutter capability chip layout 미정 |
| `market_data_status_query` | `alt operator scenario run --file testdata/operator/market_data_status_query.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/market_data_status_query.yaml` | `testdata/operator/expected/market_data_status_query.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `count` | `0` (성공/empty), `1` (mismatch) | yes | market data status card (종목수·바개수) | `import_daily_bars``ImportDailyBarsResponse.instrument_count`/`bar_count`; `ListInstrumentsResponse.instruments`, `ListBarsResponse.bars`; `ErrorInfo.code` | 차트/바 시각화 레이아웃 미정 |
| `us_market_data_status_query` | `alt operator scenario run --file testdata/operator/us_market_data_status_query.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/us_market_data_status_query.yaml` | `testdata/operator/expected/us_market_data_status_query.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `count` | `0` (성공/empty), `1` (mismatch) | yes | market data status card (종목수·바개수) | `import_daily_bars``ImportDailyBarsResponse.instrument_count`/`bar_count`; `ListInstrumentsResponse.instruments`, `ListBarsResponse.bars`; `ErrorInfo.code` | 차트/바 시각화 레이아웃 미정 |
| `market_data_status_query` | `alt operator scenario run --file testdata/operator/market_data_status_query.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/market_data_status_query.yaml` | `testdata/operator/expected/market_data_status_query.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `count`, `universe`, `freshness_status`, `missing_count`, `latest_yyyymmdd` | `0` (성공/empty), `1` (mismatch) | yes | market data status card (종목수·바개수·최신도) | `import_daily_bars``ImportDailyBarsResponse.instrument_count`/`bar_count`; `ListInstrumentsResponse.instruments`, `ListBarsResponse.bars`; `collection_freshness``freshness_status`, `missing_count`, `latest_yyyymmdd`; `ErrorInfo.code` | 차트/바 시각화 레이아웃 미정 |
| `us_market_data_status_query` | `alt operator scenario run --file testdata/operator/us_market_data_status_query.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/us_market_data_status_query.yaml` | `testdata/operator/expected/us_market_data_status_query.jsonl` | `scenario`, `status`, `type`, `action`, `provider`, `instrument_count`, `bar_count`, `count`, `universe`, `freshness_status`, `missing_count`, `latest_yyyymmdd` | `0` (성공/empty), `1` (mismatch) | yes | market data status card (종목수·바개수·최신도) | `import_daily_bars``ImportDailyBarsResponse.instrument_count`/`bar_count`; `ListInstrumentsResponse.instruments`, `ListBarsResponse.bars`; `collection_freshness``freshness_status`, `missing_count`, `latest_yyyymmdd`; `ErrorInfo.code` | 차트/바 시각화 레이아웃 미정 |
| `backtest_run_request` | `alt operator scenario run --file testdata/operator/backtest_run_request.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_run_request.yaml` | `testdata/operator/expected/backtest_run_request.jsonl` | `scenario`, `status`, `type`, `action`, `run_id`, `run_status` | `0` (성공), `1` (typed error/mismatch) | yes | backtest 생성 form (parameter 입력/검증) | `StartBacktestResponse.run.id`, `BacktestRun.status` | form validation layout 미정 |
| `backtest_run_polling` | `alt operator scenario run --file testdata/operator/backtest_run_polling.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_run_polling.yaml` | `testdata/operator/expected/backtest_run_polling.jsonl` | `scenario`, `status`, `type`, `action`, `run_id`, `run_status` | `0` (terminal 도달), `3` (polling timeout) | yes | backtest 진행 상태 표시 (상태 칩/진행바) | `GetBacktestRunResponse.run.status` 전이 (pending→running→succeeded/failed/canceled) | 진행바/상태 전이 애니메이션 미정 |
| `backtest_result_summary` | `alt operator scenario run --file testdata/operator/backtest_result_summary.yaml --api-url ws://127.0.0.1:18030/socket --output jsonl` | `testdata/operator/backtest_result_summary.yaml` | `testdata/operator/expected/backtest_result_summary.jsonl` | `scenario`, `status`, `type`, `action`, `run_id`, `starting_cash`, `ending_equity`, `total_return`, `trade_count` | `0` (성공), `1` (missing run/typed error) | yes | backtest 결과 요약 (metrics chips) | `GetBacktestResultResponse.result.summary` | 차트/메트릭 레이아웃 미정 |

View file

@ -44,3 +44,15 @@ steps:
to_unix_ms: 1747267200000
expect:
status: ok
- id: freshness_status
action: collection_freshness
request:
universe: kr-core
timeframe: daily
from_unix_ms: 1746057600000
to_unix_ms: 1747267200000
expect:
status: ok
freshness_status: fresh
missing_count: 0
latest_yyyymmdd: "20250501"

View file

@ -39,3 +39,15 @@ steps:
to_unix_ms: 1717027200000 # 2024-05-30 00:00:00 UTC
expect:
status: ok
- id: freshness_status
action: collection_freshness
request:
universe: us-core
timeframe: daily
from_unix_ms: 1716854400000
to_unix_ms: 1717027200000
expect:
status: ok
freshness_status: fresh
missing_count: 0
latest_yyyymmdd: "20240529"