feat: backtest data collection infrastructure freshness status and operator updates
This commit is contained in:
parent
d1bfc28d34
commit
8d4cb785e8
19 changed files with 1155 additions and 41 deletions
|
|
@ -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 파일을 생성한다.
|
||||
|
|
@ -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 파일을 생성한다.
|
||||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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은 허용한다.
|
||||
|
|
@ -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은 허용한다.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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, ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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` | 차트/메트릭 레이아웃 미정 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue