From 8d4cb785e8eeeb84f08281f96168c0bec1261089 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 14 Jun 2026 17:59:01 +0900 Subject: [PATCH] feat: backtest data collection infrastructure freshness status and operator updates --- .../code_review_local_G06_0.log} | 56 ++-- .../code_review_local_G07_1.log | 145 +++++++++++ .../code_review_local_G08_2.log | 150 +++++++++++ .../02+01_freshness_status/complete.log | 43 ++++ .../plan_local_G06_0.log} | 0 .../plan_local_G07_1.log | 93 +++++++ .../plan_local_G08_2.log | 78 ++++++ apps/cli/internal/operator/client_test.go | 4 + apps/cli/internal/operator/handoff_test.go | 4 +- apps/cli/internal/operator/output.go | 38 +++ apps/cli/internal/operator/runner.go | 154 +++++++++++ .../internal/operator/runner_market_test.go | 239 +++++++++++++++++- apps/cli/internal/operator/scenario.go | 59 ++++- apps/cli/internal/operator/scenario_test.go | 99 ++++++++ .../expected/market_data_status_query.jsonl | 3 +- .../us_market_data_status_query.jsonl | 3 +- .../testdata/operator/headless_validation.md | 4 +- .../operator/market_data_status_query.yaml | 12 + .../operator/us_market_data_status_query.yaml | 12 + 19 files changed, 1155 insertions(+), 41 deletions(-) rename agent-task/{m-backtest-data-collection-infrastructure/02+01_freshness_status/CODE_REVIEW-local-G06.md => archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G06_0.log} (58%) create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G07_1.log create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G08_2.log create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log rename agent-task/{m-backtest-data-collection-infrastructure/02+01_freshness_status/PLAN-local-G06.md => archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G06_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G07_1.log create mode 100644 agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G08_2.log diff --git a/agent-task/m-backtest-data-collection-infrastructure/02+01_freshness_status/CODE_REVIEW-local-G06.md b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G06_0.log similarity index 58% rename from agent-task/m-backtest-data-collection-infrastructure/02+01_freshness_status/CODE_REVIEW-local-G06.md rename to agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G06_0.log index 165f20c..8a6ba70 100644 --- a/agent-task/m-backtest-data-collection-infrastructure/02+01_freshness_status/CODE_REVIEW-local-G06.md +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G06_0.log @@ -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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고한다. +- [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=`가 실제로 고정되도록 수정하세요. +- 다음 단계: WARN/FAIL 후속 PLAN/CODE_REVIEW 파일을 생성한다. diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G07_1.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G07_1.log new file mode 100644 index 0000000..e9130f7 --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G07_1.log @@ -0,0 +1,145 @@ + + +# 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=`로 맞춘다. +- [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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 파일을 생성한다. diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G08_2.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G08_2.log new file mode 100644 index 0000000..fb3ce4b --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/code_review_local_G08_2.log @@ -0,0 +1,150 @@ + + +# 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log new file mode 100644 index 0000000..237b6fd --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-backtest-data-collection-infrastructure/02+01_freshness_status/PLAN-local-G06.md b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G06_0.log similarity index 100% rename from agent-task/m-backtest-data-collection-infrastructure/02+01_freshness_status/PLAN-local-G06.md rename to agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G06_0.log diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G07_1.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G07_1.log new file mode 100644 index 0000000..428026e --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G07_1.log @@ -0,0 +1,93 @@ + + +# 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 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은 허용한다. diff --git a/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G08_2.log b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G08_2.log new file mode 100644 index 0000000..dce9caa --- /dev/null +++ b/agent-task/archive/2026/06/m-backtest-data-collection-infrastructure/02+01_freshness_status/plan_local_G08_2.log @@ -0,0 +1,78 @@ + + +# 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은 허용한다. diff --git a/apps/cli/internal/operator/client_test.go b/apps/cli/internal/operator/client_test.go index 6233bda..82c19f6 100644 --- a/apps/cli/internal/operator/client_test.go +++ b/apps/cli/internal/operator/client_test.go @@ -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 } diff --git a/apps/cli/internal/operator/handoff_test.go b/apps/cli/internal/operator/handoff_test.go index d1cc27f..23bbea9 100644 --- a/apps/cli/internal/operator/handoff_test.go +++ b/apps/cli/internal/operator/handoff_test.go @@ -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"}, diff --git a/apps/cli/internal/operator/output.go b/apps/cli/internal/operator/output.go index 05bd109..cc0ba38 100644 --- a/apps/cli/internal/operator/output.go +++ b/apps/cli/internal/operator/output.go @@ -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 != "" { diff --git a/apps/cli/internal/operator/runner.go b/apps/cli/internal/operator/runner.go index b50bc8c..aaafc6a 100644 --- a/apps/cli/internal/operator/runner.go +++ b/apps/cli/internal/operator/runner.go @@ -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, "" +} diff --git a/apps/cli/internal/operator/runner_market_test.go b/apps/cli/internal/operator/runner_market_test.go index 9e56c33..d6a90a4 100644 --- a/apps/cli/internal/operator/runner_market_test.go +++ b/apps/cli/internal/operator/runner_market_test.go @@ -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) + } + } +} diff --git a/apps/cli/internal/operator/scenario.go b/apps/cli/internal/operator/scenario.go index 05d6a40..b7ad1bb 100644 --- a/apps/cli/internal/operator/scenario.go +++ b/apps/cli/internal/operator/scenario.go @@ -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) diff --git a/apps/cli/internal/operator/scenario_test.go b/apps/cli/internal/operator/scenario_test.go index 2401a3c..0e0016f 100644 --- a/apps/cli/internal/operator/scenario_test.go +++ b/apps/cli/internal/operator/scenario_test.go @@ -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 { diff --git a/apps/cli/testdata/operator/expected/market_data_status_query.jsonl b/apps/cli/testdata/operator/expected/market_data_status_query.jsonl index 51f038c..f24610a 100644 --- a/apps/cli/testdata/operator/expected/market_data_status_query.jsonl +++ b/apps/cli/testdata/operator/expected/market_data_status_query.jsonl @@ -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"} diff --git a/apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl b/apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl index b081c35..9a68342 100644 --- a/apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl +++ b/apps/cli/testdata/operator/expected/us_market_data_status_query.jsonl @@ -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"} diff --git a/apps/cli/testdata/operator/headless_validation.md b/apps/cli/testdata/operator/headless_validation.md index 626149a..dff56cf 100644 --- a/apps/cli/testdata/operator/headless_validation.md +++ b/apps/cli/testdata/operator/headless_validation.md @@ -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` | 차트/메트릭 레이아웃 미정 | diff --git a/apps/cli/testdata/operator/market_data_status_query.yaml b/apps/cli/testdata/operator/market_data_status_query.yaml index e5e8955..d092a8d 100644 --- a/apps/cli/testdata/operator/market_data_status_query.yaml +++ b/apps/cli/testdata/operator/market_data_status_query.yaml @@ -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" diff --git a/apps/cli/testdata/operator/us_market_data_status_query.yaml b/apps/cli/testdata/operator/us_market_data_status_query.yaml index 7cd2fb9..537b342 100644 --- a/apps/cli/testdata/operator/us_market_data_status_query.yaml +++ b/apps/cli/testdata/operator/us_market_data_status_query.yaml @@ -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"